prompt_engine 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +67 -0
- data/Rakefile +22 -0
- data/app/assets/stylesheets/prompt_engine/application.css +22 -0
- data/app/assets/stylesheets/prompt_engine/buttons.css +124 -0
- data/app/assets/stylesheets/prompt_engine/cards.css +63 -0
- data/app/assets/stylesheets/prompt_engine/comparison.css +244 -0
- data/app/assets/stylesheets/prompt_engine/components/_test_runs.css +144 -0
- data/app/assets/stylesheets/prompt_engine/dashboard.css +343 -0
- data/app/assets/stylesheets/prompt_engine/evaluations.css +124 -0
- data/app/assets/stylesheets/prompt_engine/forms.css +198 -0
- data/app/assets/stylesheets/prompt_engine/foundation.css +182 -0
- data/app/assets/stylesheets/prompt_engine/layout.css +75 -0
- data/app/assets/stylesheets/prompt_engine/loading.css +229 -0
- data/app/assets/stylesheets/prompt_engine/notifications.css +78 -0
- data/app/assets/stylesheets/prompt_engine/overrides.css +42 -0
- data/app/assets/stylesheets/prompt_engine/prompts.css +237 -0
- data/app/assets/stylesheets/prompt_engine/sidebar.css +90 -0
- data/app/assets/stylesheets/prompt_engine/tables.css +250 -0
- data/app/assets/stylesheets/prompt_engine/utilities.css +52 -0
- data/app/assets/stylesheets/prompt_engine/versions.css +370 -0
- data/app/clients/prompt_engine/open_ai_evals_client.rb +135 -0
- data/app/controllers/prompt_engine/admin/base_controller.rb +7 -0
- data/app/controllers/prompt_engine/application_controller.rb +4 -0
- data/app/controllers/prompt_engine/dashboard_controller.rb +24 -0
- data/app/controllers/prompt_engine/eval_runs_controller.rb +23 -0
- data/app/controllers/prompt_engine/eval_sets_controller.rb +200 -0
- data/app/controllers/prompt_engine/evaluations_controller.rb +32 -0
- data/app/controllers/prompt_engine/playground_controller.rb +57 -0
- data/app/controllers/prompt_engine/playground_run_results_controller.rb +41 -0
- data/app/controllers/prompt_engine/prompts_controller.rb +70 -0
- data/app/controllers/prompt_engine/settings_controller.rb +28 -0
- data/app/controllers/prompt_engine/test_cases_controller.rb +231 -0
- data/app/controllers/prompt_engine/versions_controller.rb +90 -0
- data/app/helpers/prompt_engine/application_helper.rb +4 -0
- data/app/jobs/prompt_engine/application_job.rb +4 -0
- data/app/mailers/prompt_engine/application_mailer.rb +6 -0
- data/app/models/prompt_engine/application_record.rb +5 -0
- data/app/models/prompt_engine/eval_result.rb +19 -0
- data/app/models/prompt_engine/eval_run.rb +40 -0
- data/app/models/prompt_engine/eval_set.rb +97 -0
- data/app/models/prompt_engine/parameter.rb +126 -0
- data/app/models/prompt_engine/parameter_parser.rb +39 -0
- data/app/models/prompt_engine/playground_run_result.rb +20 -0
- data/app/models/prompt_engine/prompt.rb +192 -0
- data/app/models/prompt_engine/prompt_version.rb +72 -0
- data/app/models/prompt_engine/setting.rb +45 -0
- data/app/models/prompt_engine/test_case.rb +29 -0
- data/app/services/prompt_engine/evaluation_runner.rb +258 -0
- data/app/services/prompt_engine/playground_executor.rb +124 -0
- data/app/services/prompt_engine/variable_detector.rb +97 -0
- data/app/views/layouts/prompt_engine/admin.html.erb +65 -0
- data/app/views/layouts/prompt_engine/application.html.erb +17 -0
- data/app/views/prompt_engine/dashboard/index.html.erb +230 -0
- data/app/views/prompt_engine/eval_runs/show.html.erb +204 -0
- data/app/views/prompt_engine/eval_sets/compare.html.erb +229 -0
- data/app/views/prompt_engine/eval_sets/edit.html.erb +111 -0
- data/app/views/prompt_engine/eval_sets/index.html.erb +63 -0
- data/app/views/prompt_engine/eval_sets/metrics.html.erb +371 -0
- data/app/views/prompt_engine/eval_sets/new.html.erb +113 -0
- data/app/views/prompt_engine/eval_sets/show.html.erb +235 -0
- data/app/views/prompt_engine/evaluations/index.html.erb +194 -0
- data/app/views/prompt_engine/playground/result.html.erb +58 -0
- data/app/views/prompt_engine/playground/show.html.erb +129 -0
- data/app/views/prompt_engine/playground_run_results/index.html.erb +99 -0
- data/app/views/prompt_engine/playground_run_results/show.html.erb +123 -0
- data/app/views/prompt_engine/prompts/_form.html.erb +224 -0
- data/app/views/prompt_engine/prompts/edit.html.erb +9 -0
- data/app/views/prompt_engine/prompts/index.html.erb +80 -0
- data/app/views/prompt_engine/prompts/new.html.erb +9 -0
- data/app/views/prompt_engine/prompts/show.html.erb +297 -0
- data/app/views/prompt_engine/settings/edit.html.erb +93 -0
- data/app/views/prompt_engine/shared/_form_errors.html.erb +16 -0
- data/app/views/prompt_engine/test_cases/edit.html.erb +72 -0
- data/app/views/prompt_engine/test_cases/import.html.erb +92 -0
- data/app/views/prompt_engine/test_cases/import_preview.html.erb +103 -0
- data/app/views/prompt_engine/test_cases/new.html.erb +79 -0
- data/app/views/prompt_engine/versions/_version_card.html.erb +56 -0
- data/app/views/prompt_engine/versions/compare.html.erb +82 -0
- data/app/views/prompt_engine/versions/index.html.erb +96 -0
- data/app/views/prompt_engine/versions/show.html.erb +98 -0
- data/config/routes.rb +61 -0
- data/db/migrate/20250124000001_create_eval_tables.rb +43 -0
- data/db/migrate/20250124000002_add_open_ai_fields_to_evals.rb +11 -0
- data/db/migrate/20250125000001_add_grader_fields_to_eval_sets.rb +8 -0
- data/db/migrate/20250723161909_create_prompts.rb +17 -0
- data/db/migrate/20250723184757_create_prompt_engine_versions.rb +24 -0
- data/db/migrate/20250723203838_create_prompt_engine_parameters.rb +20 -0
- data/db/migrate/20250724160623_create_prompt_engine_playground_run_results.rb +30 -0
- data/db/migrate/20250724165118_create_prompt_engine_settings.rb +14 -0
- data/lib/prompt_engine/engine.rb +25 -0
- data/lib/prompt_engine/version.rb +3 -0
- data/lib/prompt_engine.rb +33 -0
- data/lib/tasks/active_prompt_tasks.rake +32 -0
- data/lib/tasks/eval_demo.rake +149 -0
- metadata +293 -0
@@ -0,0 +1,72 @@
|
|
1
|
+
module PromptEngine
|
2
|
+
class PromptVersion < ApplicationRecord
|
3
|
+
self.table_name = "prompt_engine_prompt_versions"
|
4
|
+
|
5
|
+
belongs_to :prompt, class_name: "PromptEngine::Prompt", counter_cache: :versions_count
|
6
|
+
has_many :playground_run_results, class_name: "PromptEngine::PlaygroundRunResult", dependent: :destroy
|
7
|
+
has_many :eval_runs, class_name: "PromptEngine::EvalRun", dependent: :destroy
|
8
|
+
|
9
|
+
validates :version_number, presence: true,
|
10
|
+
numericality: { greater_than: 0 },
|
11
|
+
uniqueness: { scope: :prompt_id }
|
12
|
+
validates :content, presence: true
|
13
|
+
|
14
|
+
before_validation :set_version_number, on: :create
|
15
|
+
validate :ensure_immutability, on: :update
|
16
|
+
|
17
|
+
scope :latest, -> { order(version_number: :desc) }
|
18
|
+
scope :chronological, -> { order(created_at: :asc) }
|
19
|
+
|
20
|
+
def restore!
|
21
|
+
# Update the prompt attributes
|
22
|
+
prompt.update!(to_prompt_attributes)
|
23
|
+
|
24
|
+
# Check if a version was created (attributes changed)
|
25
|
+
latest_version = prompt.versions.first
|
26
|
+
|
27
|
+
if latest_version.created_at > 1.second.ago
|
28
|
+
# A new version was just created, update its description
|
29
|
+
latest_version.update_column(:change_description, "Restored from version #{version_number}")
|
30
|
+
else
|
31
|
+
# No version was created (no changes), create one manually
|
32
|
+
prompt.versions.create!(
|
33
|
+
to_prompt_attributes.merge(
|
34
|
+
change_description: "Restored from version #{version_number}"
|
35
|
+
)
|
36
|
+
)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def to_prompt_attributes
|
41
|
+
{
|
42
|
+
content: content,
|
43
|
+
system_message: system_message,
|
44
|
+
model: model,
|
45
|
+
temperature: temperature,
|
46
|
+
max_tokens: max_tokens,
|
47
|
+
metadata: metadata
|
48
|
+
}
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
def set_version_number
|
54
|
+
return if version_number.present?
|
55
|
+
return unless prompt
|
56
|
+
|
57
|
+
max_version = prompt.versions.maximum(:version_number) || 0
|
58
|
+
self.version_number = max_version + 1
|
59
|
+
end
|
60
|
+
|
61
|
+
def ensure_immutability
|
62
|
+
immutable_attributes = %w[content system_message model temperature max_tokens]
|
63
|
+
changed_immutable = (changed & immutable_attributes)
|
64
|
+
|
65
|
+
if changed_immutable.any?
|
66
|
+
changed_immutable.each do |attr|
|
67
|
+
errors.add(attr, "cannot be changed after creation")
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
module PromptEngine
|
2
|
+
class Setting < ApplicationRecord
|
3
|
+
self.table_name = "prompt_engine_settings"
|
4
|
+
|
5
|
+
# Rails automatically encrypts these attributes
|
6
|
+
encrypts :openai_api_key
|
7
|
+
encrypts :anthropic_api_key
|
8
|
+
|
9
|
+
# Singleton pattern - only one settings record should exist
|
10
|
+
def self.instance
|
11
|
+
first_or_create!
|
12
|
+
end
|
13
|
+
|
14
|
+
# Check if API keys are configured
|
15
|
+
def openai_configured?
|
16
|
+
openai_api_key.present?
|
17
|
+
end
|
18
|
+
|
19
|
+
def anthropic_configured?
|
20
|
+
anthropic_api_key.present?
|
21
|
+
end
|
22
|
+
|
23
|
+
# Get masked API key for display (show only first and last 3 characters)
|
24
|
+
def masked_openai_api_key
|
25
|
+
mask_api_key(openai_api_key)
|
26
|
+
end
|
27
|
+
|
28
|
+
def masked_anthropic_api_key
|
29
|
+
mask_api_key(anthropic_api_key)
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def mask_api_key(key)
|
35
|
+
return nil if key.blank?
|
36
|
+
return "*****" if key.length <= 6
|
37
|
+
|
38
|
+
# Show first 3 characters, then ..., then last 3 characters
|
39
|
+
# e.g., "sk-abc123xyz789" becomes "sk-...789"
|
40
|
+
first_part = key[0..2] # First 3 characters
|
41
|
+
last_part = key[-3..] # Last 3 characters
|
42
|
+
"#{first_part}...#{last_part}"
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module PromptEngine
|
2
|
+
class TestCase < ApplicationRecord
|
3
|
+
belongs_to :eval_set
|
4
|
+
has_many :eval_results, dependent: :destroy
|
5
|
+
|
6
|
+
validates :input_variables, presence: true
|
7
|
+
validates :expected_output, presence: true
|
8
|
+
|
9
|
+
scope :by_description, -> { order(:description) }
|
10
|
+
|
11
|
+
def display_name
|
12
|
+
description.presence || "Test case ##{id}"
|
13
|
+
end
|
14
|
+
|
15
|
+
def passed_count
|
16
|
+
eval_results.where(passed: true).count
|
17
|
+
end
|
18
|
+
|
19
|
+
def failed_count
|
20
|
+
eval_results.where(passed: false).count
|
21
|
+
end
|
22
|
+
|
23
|
+
def success_rate
|
24
|
+
total = eval_results.count
|
25
|
+
return 0 if total.zero?
|
26
|
+
(passed_count.to_f / total * 100).round(1)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,258 @@
|
|
1
|
+
# Client is autoloaded by Rails
|
2
|
+
|
3
|
+
module PromptEngine
|
4
|
+
class EvaluationRunner
|
5
|
+
def initialize(eval_run)
|
6
|
+
@eval_run = eval_run
|
7
|
+
@eval_set = eval_run.eval_set
|
8
|
+
@prompt_version = eval_run.prompt_version
|
9
|
+
@prompt = @prompt_version.prompt
|
10
|
+
@client = OpenAiEvalsClient.new
|
11
|
+
end
|
12
|
+
|
13
|
+
def execute
|
14
|
+
@eval_run.update!(status: :running, started_at: Time.current)
|
15
|
+
|
16
|
+
# Step 1: Create or get OpenAI eval configuration
|
17
|
+
ensure_openai_eval_exists
|
18
|
+
|
19
|
+
# Step 2: Create test data file in JSONL format
|
20
|
+
file_id = upload_test_data
|
21
|
+
@eval_run.update!(openai_file_id: file_id)
|
22
|
+
|
23
|
+
# Step 3: Create eval run on OpenAI
|
24
|
+
openai_run = create_openai_run(file_id)
|
25
|
+
@eval_run.update!(
|
26
|
+
openai_run_id: openai_run["id"],
|
27
|
+
report_url: openai_run["report_url"]
|
28
|
+
)
|
29
|
+
|
30
|
+
# Step 4: Poll for results
|
31
|
+
poll_for_results
|
32
|
+
|
33
|
+
rescue => e
|
34
|
+
@eval_run.update!(status: :failed, error_message: e.message)
|
35
|
+
raise
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def ensure_openai_eval_exists
|
41
|
+
return if @eval_set.openai_eval_id.present?
|
42
|
+
|
43
|
+
# Build schema properties dynamically based on prompt parameters
|
44
|
+
schema_properties = {}
|
45
|
+
required_fields = []
|
46
|
+
|
47
|
+
# Add properties for each parameter in the prompt
|
48
|
+
@prompt.parameters.each do |param|
|
49
|
+
schema_properties[param.name] = { type: parameter_type_to_json_schema(param.parameter_type) }
|
50
|
+
required_fields << param.name if param.required?
|
51
|
+
end
|
52
|
+
|
53
|
+
# Always include expected_output
|
54
|
+
schema_properties["expected_output"] = { type: "string" }
|
55
|
+
required_fields << "expected_output"
|
56
|
+
|
57
|
+
# Create eval configuration on OpenAI
|
58
|
+
eval_config = @client.create_eval(
|
59
|
+
name: "#{@prompt.name} - #{@eval_set.name}",
|
60
|
+
data_source_config: {
|
61
|
+
type: "custom",
|
62
|
+
item_schema: {
|
63
|
+
type: "object",
|
64
|
+
properties: schema_properties,
|
65
|
+
required: required_fields
|
66
|
+
},
|
67
|
+
include_sample_schema: true
|
68
|
+
},
|
69
|
+
testing_criteria: build_testing_criteria
|
70
|
+
)
|
71
|
+
|
72
|
+
@eval_set.update!(openai_eval_id: eval_config["id"])
|
73
|
+
end
|
74
|
+
|
75
|
+
def upload_test_data
|
76
|
+
# Create temporary JSONL file
|
77
|
+
file_path = Rails.root.join("tmp", "eval_#{@eval_run.id}.jsonl")
|
78
|
+
|
79
|
+
File.open(file_path, "w") do |file|
|
80
|
+
@eval_set.test_cases.each do |test_case|
|
81
|
+
# Flatten the structure - put input variables directly on item
|
82
|
+
item_data = test_case.input_variables.dup
|
83
|
+
item_data["expected_output"] = test_case.expected_output
|
84
|
+
|
85
|
+
line = { item: item_data }
|
86
|
+
file.puts(line.to_json)
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
# Upload to OpenAI
|
91
|
+
response = @client.upload_file(file_path)
|
92
|
+
|
93
|
+
# Clean up
|
94
|
+
File.delete(file_path)
|
95
|
+
|
96
|
+
response["id"]
|
97
|
+
end
|
98
|
+
|
99
|
+
def create_openai_run(file_id)
|
100
|
+
# Build message template with prompt content
|
101
|
+
messages_template = [
|
102
|
+
{
|
103
|
+
role: "system",
|
104
|
+
content: @prompt_version.system_message || ""
|
105
|
+
},
|
106
|
+
{
|
107
|
+
role: "user",
|
108
|
+
content: build_templated_content
|
109
|
+
}
|
110
|
+
]
|
111
|
+
|
112
|
+
@client.create_run(
|
113
|
+
eval_id: @eval_set.openai_eval_id,
|
114
|
+
name: "Run at #{Time.current}",
|
115
|
+
data_source: {
|
116
|
+
type: "completions",
|
117
|
+
model: @prompt_version.model || "gpt-4",
|
118
|
+
input_messages: {
|
119
|
+
type: "template",
|
120
|
+
template: messages_template
|
121
|
+
},
|
122
|
+
source: {
|
123
|
+
type: "file_id",
|
124
|
+
id: file_id
|
125
|
+
}
|
126
|
+
}
|
127
|
+
)
|
128
|
+
end
|
129
|
+
|
130
|
+
def build_templated_content
|
131
|
+
# Convert our {{variable}} syntax to OpenAI's template syntax
|
132
|
+
content = @prompt_version.content.dup
|
133
|
+
|
134
|
+
# Replace {{variable}} with {{ item.variable }} (flat structure)
|
135
|
+
content.gsub(/\{\{(\w+)\}\}/) do |match|
|
136
|
+
variable_name = $1
|
137
|
+
"{{ item.#{variable_name} }}"
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
def poll_for_results
|
142
|
+
max_attempts = 60 # 5 minutes with 5 second intervals
|
143
|
+
attempts = 0
|
144
|
+
|
145
|
+
loop do
|
146
|
+
attempts += 1
|
147
|
+
|
148
|
+
run_status = @client.get_run(
|
149
|
+
eval_id: @eval_set.openai_eval_id,
|
150
|
+
run_id: @eval_run.openai_run_id
|
151
|
+
)
|
152
|
+
|
153
|
+
case run_status["status"]
|
154
|
+
when "completed"
|
155
|
+
process_results(run_status)
|
156
|
+
break
|
157
|
+
when "failed", "canceled"
|
158
|
+
@eval_run.update!(
|
159
|
+
status: :failed,
|
160
|
+
error_message: run_status["error"] || "Eval run #{run_status["status"]}"
|
161
|
+
)
|
162
|
+
break
|
163
|
+
else
|
164
|
+
# Still running
|
165
|
+
if attempts >= max_attempts
|
166
|
+
@eval_run.update!(
|
167
|
+
status: :failed,
|
168
|
+
error_message: "Timeout waiting for eval results"
|
169
|
+
)
|
170
|
+
break
|
171
|
+
end
|
172
|
+
|
173
|
+
sleep 5
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
def process_results(run_status)
|
179
|
+
# Extract counts from OpenAI response
|
180
|
+
result_counts = run_status["result_counts"] || {}
|
181
|
+
|
182
|
+
@eval_run.update!(
|
183
|
+
status: :completed,
|
184
|
+
completed_at: Time.current,
|
185
|
+
total_count: result_counts["total"] || 0,
|
186
|
+
passed_count: result_counts["passed"] || 0,
|
187
|
+
failed_count: result_counts["failed"] || 0
|
188
|
+
)
|
189
|
+
|
190
|
+
# Note: Individual test results would need to be fetched separately
|
191
|
+
# For MVP, we just store the aggregate counts
|
192
|
+
end
|
193
|
+
|
194
|
+
def build_testing_criteria
|
195
|
+
case @eval_set.grader_type
|
196
|
+
when "exact_match"
|
197
|
+
[ {
|
198
|
+
type: "string_check",
|
199
|
+
name: "Exact match",
|
200
|
+
input: "{{ sample.output_text }}",
|
201
|
+
operation: "eq",
|
202
|
+
reference: "{{ item.expected_output }}"
|
203
|
+
} ]
|
204
|
+
when "regex"
|
205
|
+
[ {
|
206
|
+
type: "string_check",
|
207
|
+
name: "Regex match",
|
208
|
+
input: "{{ sample.output_text }}",
|
209
|
+
operation: "regex",
|
210
|
+
reference: @eval_set.grader_config["pattern"]
|
211
|
+
} ]
|
212
|
+
when "contains"
|
213
|
+
[ {
|
214
|
+
type: "string_check",
|
215
|
+
name: "Contains text",
|
216
|
+
input: "{{ sample.output_text }}",
|
217
|
+
operation: "contains",
|
218
|
+
reference: "{{ item.expected_output }}"
|
219
|
+
} ]
|
220
|
+
when "json_schema"
|
221
|
+
# OpenAI doesn't support json_schema_check directly
|
222
|
+
# For MVP, we'll just verify the output is valid JSON format
|
223
|
+
# and matches the expected output exactly
|
224
|
+
[ {
|
225
|
+
type: "string_check",
|
226
|
+
name: "JSON format validation",
|
227
|
+
input: "{{ sample.output_text }}",
|
228
|
+
operation: "eq",
|
229
|
+
reference: "{{ item.expected_output }}"
|
230
|
+
} ]
|
231
|
+
else
|
232
|
+
# Default to exact match
|
233
|
+
[ {
|
234
|
+
type: "string_check",
|
235
|
+
name: "Exact match",
|
236
|
+
input: "{{ sample.output_text }}",
|
237
|
+
operation: "eq",
|
238
|
+
reference: "{{ item.expected_output }}"
|
239
|
+
} ]
|
240
|
+
end
|
241
|
+
end
|
242
|
+
|
243
|
+
def parameter_type_to_json_schema(type)
|
244
|
+
case type
|
245
|
+
when "integer"
|
246
|
+
"integer"
|
247
|
+
when "decimal"
|
248
|
+
"number"
|
249
|
+
when "boolean"
|
250
|
+
"boolean"
|
251
|
+
when "array", "json"
|
252
|
+
"array"
|
253
|
+
else
|
254
|
+
"string"
|
255
|
+
end
|
256
|
+
end
|
257
|
+
end
|
258
|
+
end
|
@@ -0,0 +1,124 @@
|
|
1
|
+
module PromptEngine
|
2
|
+
class PlaygroundExecutor
|
3
|
+
attr_reader :prompt, :provider, :api_key, :parameters
|
4
|
+
|
5
|
+
MODELS = {
|
6
|
+
"anthropic" => "claude-3-5-sonnet-20241022",
|
7
|
+
"openai" => "gpt-4o"
|
8
|
+
}.freeze
|
9
|
+
|
10
|
+
def initialize(prompt:, provider:, api_key:, parameters: {})
|
11
|
+
@prompt = prompt
|
12
|
+
@provider = provider
|
13
|
+
@api_key = api_key
|
14
|
+
@parameters = parameters || {}
|
15
|
+
end
|
16
|
+
|
17
|
+
def execute
|
18
|
+
validate_inputs!
|
19
|
+
|
20
|
+
start_time = Time.current
|
21
|
+
|
22
|
+
# Replace parameters in prompt content
|
23
|
+
parser = ParameterParser.new(prompt.content)
|
24
|
+
processed_content = parser.replace_parameters(parameters)
|
25
|
+
|
26
|
+
# Configure RubyLLM with the appropriate API key
|
27
|
+
configure_ruby_llm
|
28
|
+
|
29
|
+
# Create chat instance with the model
|
30
|
+
chat = RubyLLM.chat(model: MODELS[provider])
|
31
|
+
|
32
|
+
# Apply temperature if specified
|
33
|
+
if prompt.temperature.present?
|
34
|
+
chat = chat.with_temperature(prompt.temperature)
|
35
|
+
end
|
36
|
+
|
37
|
+
# Apply system message if present
|
38
|
+
if prompt.system_message.present?
|
39
|
+
chat = chat.with_instructions(prompt.system_message)
|
40
|
+
end
|
41
|
+
|
42
|
+
# Execute the prompt
|
43
|
+
# Note: max_tokens may need to be passed differently depending on RubyLLM version
|
44
|
+
response = chat.ask(processed_content)
|
45
|
+
|
46
|
+
execution_time = (Time.current - start_time).round(3)
|
47
|
+
|
48
|
+
# Handle response based on its structure
|
49
|
+
response_content = if response.respond_to?(:content)
|
50
|
+
response.content
|
51
|
+
elsif response.is_a?(String)
|
52
|
+
response
|
53
|
+
else
|
54
|
+
response.to_s
|
55
|
+
end
|
56
|
+
|
57
|
+
# Try to get token count if available
|
58
|
+
token_count = if response.respond_to?(:input_tokens) && response.respond_to?(:output_tokens)
|
59
|
+
(response.input_tokens || 0) + (response.output_tokens || 0)
|
60
|
+
else
|
61
|
+
0 # Default if token information isn't available
|
62
|
+
end
|
63
|
+
|
64
|
+
{
|
65
|
+
response: response_content,
|
66
|
+
execution_time: execution_time,
|
67
|
+
token_count: token_count,
|
68
|
+
model: MODELS[provider],
|
69
|
+
provider: provider
|
70
|
+
}
|
71
|
+
rescue StandardError => e
|
72
|
+
handle_error(e)
|
73
|
+
end
|
74
|
+
|
75
|
+
private
|
76
|
+
|
77
|
+
def validate_inputs!
|
78
|
+
raise ArgumentError, "Provider is required" if provider.blank?
|
79
|
+
raise ArgumentError, "API key is required" if api_key.blank?
|
80
|
+
raise ArgumentError, "Invalid provider" unless MODELS.key?(provider)
|
81
|
+
end
|
82
|
+
|
83
|
+
def configure_ruby_llm
|
84
|
+
require "ruby_llm"
|
85
|
+
|
86
|
+
RubyLLM.configure do |config|
|
87
|
+
case provider
|
88
|
+
when "anthropic"
|
89
|
+
config.anthropic_api_key = api_key
|
90
|
+
when "openai"
|
91
|
+
config.openai_api_key = api_key
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
def handle_error(error)
|
97
|
+
# Re-raise ArgumentError as-is for validation errors
|
98
|
+
raise error if error.is_a?(ArgumentError)
|
99
|
+
|
100
|
+
# Check for specific error types first
|
101
|
+
case error
|
102
|
+
when Net::HTTPUnauthorized
|
103
|
+
raise "Invalid API key"
|
104
|
+
when Net::HTTPTooManyRequests
|
105
|
+
raise "Rate limit exceeded. Please try again later."
|
106
|
+
when Net::HTTPError
|
107
|
+
raise "Network error. Please check your connection and try again."
|
108
|
+
else
|
109
|
+
# Then check error message patterns
|
110
|
+
error_message = error.message.to_s
|
111
|
+
case error_message
|
112
|
+
when /unauthorized/i
|
113
|
+
raise "Invalid API key"
|
114
|
+
when /rate limit/i
|
115
|
+
raise "Rate limit exceeded. Please try again later."
|
116
|
+
when /network/i
|
117
|
+
raise "Network error. Please check your connection and try again."
|
118
|
+
else
|
119
|
+
raise "An error occurred: #{error.message}"
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
module PromptEngine
|
2
|
+
class VariableDetector
|
3
|
+
# Regex pattern to match {{variable_name}} syntax
|
4
|
+
# Allows letters, numbers, underscores, and dots for nested variables
|
5
|
+
VARIABLE_PATTERN = /\{\{([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)\}\}/
|
6
|
+
|
7
|
+
attr_reader :content
|
8
|
+
|
9
|
+
def initialize(content)
|
10
|
+
@content = content.to_s
|
11
|
+
end
|
12
|
+
|
13
|
+
# Extract all variables from the content
|
14
|
+
def extract_variables
|
15
|
+
variables = []
|
16
|
+
|
17
|
+
content.scan(VARIABLE_PATTERN) do |match|
|
18
|
+
variable_name = match.first
|
19
|
+
variables << {
|
20
|
+
name: variable_name,
|
21
|
+
placeholder: "{{#{variable_name}}}",
|
22
|
+
position: Regexp.last_match.offset(0),
|
23
|
+
type: infer_type(variable_name),
|
24
|
+
required: true
|
25
|
+
}
|
26
|
+
end
|
27
|
+
|
28
|
+
# Remove duplicates while preserving order
|
29
|
+
variables.uniq { |v| v[:name] }
|
30
|
+
end
|
31
|
+
|
32
|
+
# Get just the variable names as an array
|
33
|
+
def variable_names
|
34
|
+
extract_variables.map { |v| v[:name] }
|
35
|
+
end
|
36
|
+
|
37
|
+
# Check if content contains variables
|
38
|
+
def has_variables?
|
39
|
+
content.match?(VARIABLE_PATTERN)
|
40
|
+
end
|
41
|
+
|
42
|
+
# Count unique variables
|
43
|
+
def variable_count
|
44
|
+
variable_names.count
|
45
|
+
end
|
46
|
+
|
47
|
+
# Replace variables with provided values
|
48
|
+
def render(variables = {})
|
49
|
+
rendered = content.dup
|
50
|
+
|
51
|
+
variables.each do |key, value|
|
52
|
+
# Support both string and symbol keys
|
53
|
+
placeholder = "{{#{key}}}"
|
54
|
+
rendered.gsub!(placeholder, value.to_s)
|
55
|
+
end
|
56
|
+
|
57
|
+
rendered
|
58
|
+
end
|
59
|
+
|
60
|
+
# Validate that all required variables are provided
|
61
|
+
def validate_variables(provided_variables = {})
|
62
|
+
missing = []
|
63
|
+
stringified_keys = provided_variables.stringify_keys
|
64
|
+
|
65
|
+
variable_names.each do |var_name|
|
66
|
+
unless stringified_keys.key?(var_name)
|
67
|
+
missing << var_name
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
{
|
72
|
+
valid: missing.empty?,
|
73
|
+
missing_variables: missing
|
74
|
+
}
|
75
|
+
end
|
76
|
+
|
77
|
+
private
|
78
|
+
|
79
|
+
# Attempt to infer type from variable name
|
80
|
+
def infer_type(variable_name)
|
81
|
+
case variable_name.downcase
|
82
|
+
when /(_id|_count|_number|_qty|_quantity)$/
|
83
|
+
"integer"
|
84
|
+
when /(_at|_date|_time)$/
|
85
|
+
"datetime"
|
86
|
+
when /(_price|_amount|_cost|_total)$/
|
87
|
+
"decimal"
|
88
|
+
when /(is_|has_|can_|should_)/
|
89
|
+
"boolean"
|
90
|
+
when /(_list|_array|_items)$/
|
91
|
+
"array"
|
92
|
+
else
|
93
|
+
"string"
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html lang="en">
|
3
|
+
<head>
|
4
|
+
<title>PromptEngine Admin</title>
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
6
|
+
<%= csrf_meta_tags %>
|
7
|
+
<%= csp_meta_tag %>
|
8
|
+
|
9
|
+
<%= stylesheet_link_tag "prompt_engine/application", "data-turbo-track": "reload" %>
|
10
|
+
</head>
|
11
|
+
|
12
|
+
<body class="prompt-engine-admin">
|
13
|
+
<div class="admin-layout">
|
14
|
+
<aside class="admin-layout__sidebar">
|
15
|
+
<div class="admin-sidebar">
|
16
|
+
<div class="admin-sidebar__header">
|
17
|
+
<h1 class="admin-sidebar__title">PromptEngine</h1>
|
18
|
+
<span class="admin-sidebar__version">v<%= PromptEngine::VERSION %></span>
|
19
|
+
</div>
|
20
|
+
|
21
|
+
<nav class="admin-sidebar__nav">
|
22
|
+
<ul class="admin-nav">
|
23
|
+
<li class="admin-nav__item">
|
24
|
+
<%= link_to "Dashboard", root_path,
|
25
|
+
class: "admin-nav__link #{'admin-nav__link--active' if controller_name == 'dashboard'}" %>
|
26
|
+
</li>
|
27
|
+
<li class="admin-nav__item">
|
28
|
+
<%= link_to "Prompts", prompts_path,
|
29
|
+
class: "admin-nav__link #{'admin-nav__link--active' if %w[prompts versions playground].include?(controller_name)}" %>
|
30
|
+
</li>
|
31
|
+
<li class="admin-nav__item">
|
32
|
+
<%= link_to "Evaluations", evaluations_path,
|
33
|
+
class: "admin-nav__link #{'admin-nav__link--active' if %w[eval_sets eval_runs test_cases].include?(controller_name)}" %>
|
34
|
+
</li>
|
35
|
+
<li class="admin-nav__item">
|
36
|
+
<%= link_to "Test Runs", playground_run_results_path,
|
37
|
+
class: "admin-nav__link #{'admin-nav__link--active' if controller_name == 'playground_run_results'}" %>
|
38
|
+
</li>
|
39
|
+
<li class="admin-nav__item">
|
40
|
+
<%= link_to "Settings", edit_settings_path,
|
41
|
+
class: "admin-nav__link #{'admin-nav__link--active' if controller_name == 'settings'}" %>
|
42
|
+
</li>
|
43
|
+
</ul>
|
44
|
+
</nav>
|
45
|
+
</div>
|
46
|
+
</aside>
|
47
|
+
|
48
|
+
<main class="admin-layout__main">
|
49
|
+
<div class="admin-main">
|
50
|
+
<% if flash.any? %>
|
51
|
+
<div class="admin-notifications">
|
52
|
+
<% flash.each do |type, message| %>
|
53
|
+
<div class="admin-notification admin-notification--<%= type %>">
|
54
|
+
<%= message %>
|
55
|
+
</div>
|
56
|
+
<% end %>
|
57
|
+
</div>
|
58
|
+
<% end %>
|
59
|
+
|
60
|
+
<%= yield %>
|
61
|
+
</div>
|
62
|
+
</main>
|
63
|
+
</div>
|
64
|
+
</body>
|
65
|
+
</html>
|