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.
Files changed (97) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +67 -0
  4. data/Rakefile +22 -0
  5. data/app/assets/stylesheets/prompt_engine/application.css +22 -0
  6. data/app/assets/stylesheets/prompt_engine/buttons.css +124 -0
  7. data/app/assets/stylesheets/prompt_engine/cards.css +63 -0
  8. data/app/assets/stylesheets/prompt_engine/comparison.css +244 -0
  9. data/app/assets/stylesheets/prompt_engine/components/_test_runs.css +144 -0
  10. data/app/assets/stylesheets/prompt_engine/dashboard.css +343 -0
  11. data/app/assets/stylesheets/prompt_engine/evaluations.css +124 -0
  12. data/app/assets/stylesheets/prompt_engine/forms.css +198 -0
  13. data/app/assets/stylesheets/prompt_engine/foundation.css +182 -0
  14. data/app/assets/stylesheets/prompt_engine/layout.css +75 -0
  15. data/app/assets/stylesheets/prompt_engine/loading.css +229 -0
  16. data/app/assets/stylesheets/prompt_engine/notifications.css +78 -0
  17. data/app/assets/stylesheets/prompt_engine/overrides.css +42 -0
  18. data/app/assets/stylesheets/prompt_engine/prompts.css +237 -0
  19. data/app/assets/stylesheets/prompt_engine/sidebar.css +90 -0
  20. data/app/assets/stylesheets/prompt_engine/tables.css +250 -0
  21. data/app/assets/stylesheets/prompt_engine/utilities.css +52 -0
  22. data/app/assets/stylesheets/prompt_engine/versions.css +370 -0
  23. data/app/clients/prompt_engine/open_ai_evals_client.rb +135 -0
  24. data/app/controllers/prompt_engine/admin/base_controller.rb +7 -0
  25. data/app/controllers/prompt_engine/application_controller.rb +4 -0
  26. data/app/controllers/prompt_engine/dashboard_controller.rb +24 -0
  27. data/app/controllers/prompt_engine/eval_runs_controller.rb +23 -0
  28. data/app/controllers/prompt_engine/eval_sets_controller.rb +200 -0
  29. data/app/controllers/prompt_engine/evaluations_controller.rb +32 -0
  30. data/app/controllers/prompt_engine/playground_controller.rb +57 -0
  31. data/app/controllers/prompt_engine/playground_run_results_controller.rb +41 -0
  32. data/app/controllers/prompt_engine/prompts_controller.rb +70 -0
  33. data/app/controllers/prompt_engine/settings_controller.rb +28 -0
  34. data/app/controllers/prompt_engine/test_cases_controller.rb +231 -0
  35. data/app/controllers/prompt_engine/versions_controller.rb +90 -0
  36. data/app/helpers/prompt_engine/application_helper.rb +4 -0
  37. data/app/jobs/prompt_engine/application_job.rb +4 -0
  38. data/app/mailers/prompt_engine/application_mailer.rb +6 -0
  39. data/app/models/prompt_engine/application_record.rb +5 -0
  40. data/app/models/prompt_engine/eval_result.rb +19 -0
  41. data/app/models/prompt_engine/eval_run.rb +40 -0
  42. data/app/models/prompt_engine/eval_set.rb +97 -0
  43. data/app/models/prompt_engine/parameter.rb +126 -0
  44. data/app/models/prompt_engine/parameter_parser.rb +39 -0
  45. data/app/models/prompt_engine/playground_run_result.rb +20 -0
  46. data/app/models/prompt_engine/prompt.rb +192 -0
  47. data/app/models/prompt_engine/prompt_version.rb +72 -0
  48. data/app/models/prompt_engine/setting.rb +45 -0
  49. data/app/models/prompt_engine/test_case.rb +29 -0
  50. data/app/services/prompt_engine/evaluation_runner.rb +258 -0
  51. data/app/services/prompt_engine/playground_executor.rb +124 -0
  52. data/app/services/prompt_engine/variable_detector.rb +97 -0
  53. data/app/views/layouts/prompt_engine/admin.html.erb +65 -0
  54. data/app/views/layouts/prompt_engine/application.html.erb +17 -0
  55. data/app/views/prompt_engine/dashboard/index.html.erb +230 -0
  56. data/app/views/prompt_engine/eval_runs/show.html.erb +204 -0
  57. data/app/views/prompt_engine/eval_sets/compare.html.erb +229 -0
  58. data/app/views/prompt_engine/eval_sets/edit.html.erb +111 -0
  59. data/app/views/prompt_engine/eval_sets/index.html.erb +63 -0
  60. data/app/views/prompt_engine/eval_sets/metrics.html.erb +371 -0
  61. data/app/views/prompt_engine/eval_sets/new.html.erb +113 -0
  62. data/app/views/prompt_engine/eval_sets/show.html.erb +235 -0
  63. data/app/views/prompt_engine/evaluations/index.html.erb +194 -0
  64. data/app/views/prompt_engine/playground/result.html.erb +58 -0
  65. data/app/views/prompt_engine/playground/show.html.erb +129 -0
  66. data/app/views/prompt_engine/playground_run_results/index.html.erb +99 -0
  67. data/app/views/prompt_engine/playground_run_results/show.html.erb +123 -0
  68. data/app/views/prompt_engine/prompts/_form.html.erb +224 -0
  69. data/app/views/prompt_engine/prompts/edit.html.erb +9 -0
  70. data/app/views/prompt_engine/prompts/index.html.erb +80 -0
  71. data/app/views/prompt_engine/prompts/new.html.erb +9 -0
  72. data/app/views/prompt_engine/prompts/show.html.erb +297 -0
  73. data/app/views/prompt_engine/settings/edit.html.erb +93 -0
  74. data/app/views/prompt_engine/shared/_form_errors.html.erb +16 -0
  75. data/app/views/prompt_engine/test_cases/edit.html.erb +72 -0
  76. data/app/views/prompt_engine/test_cases/import.html.erb +92 -0
  77. data/app/views/prompt_engine/test_cases/import_preview.html.erb +103 -0
  78. data/app/views/prompt_engine/test_cases/new.html.erb +79 -0
  79. data/app/views/prompt_engine/versions/_version_card.html.erb +56 -0
  80. data/app/views/prompt_engine/versions/compare.html.erb +82 -0
  81. data/app/views/prompt_engine/versions/index.html.erb +96 -0
  82. data/app/views/prompt_engine/versions/show.html.erb +98 -0
  83. data/config/routes.rb +61 -0
  84. data/db/migrate/20250124000001_create_eval_tables.rb +43 -0
  85. data/db/migrate/20250124000002_add_open_ai_fields_to_evals.rb +11 -0
  86. data/db/migrate/20250125000001_add_grader_fields_to_eval_sets.rb +8 -0
  87. data/db/migrate/20250723161909_create_prompts.rb +17 -0
  88. data/db/migrate/20250723184757_create_prompt_engine_versions.rb +24 -0
  89. data/db/migrate/20250723203838_create_prompt_engine_parameters.rb +20 -0
  90. data/db/migrate/20250724160623_create_prompt_engine_playground_run_results.rb +30 -0
  91. data/db/migrate/20250724165118_create_prompt_engine_settings.rb +14 -0
  92. data/lib/prompt_engine/engine.rb +25 -0
  93. data/lib/prompt_engine/version.rb +3 -0
  94. data/lib/prompt_engine.rb +33 -0
  95. data/lib/tasks/active_prompt_tasks.rake +32 -0
  96. data/lib/tasks/eval_demo.rake +149 -0
  97. 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>