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,90 @@
1
+ module PromptEngine
2
+ class VersionsController < ApplicationController
3
+ layout "prompt_engine/admin"
4
+
5
+ before_action :set_prompt
6
+ before_action :set_version, only: [ :show, :restore ]
7
+ before_action :set_compare_versions, only: [ :compare ]
8
+
9
+ def index
10
+ @versions = @prompt.versions
11
+ end
12
+
13
+ def show
14
+ end
15
+
16
+ def compare
17
+ if @version_a && @version_b
18
+ @changes = calculate_changes(@version_a, @version_b)
19
+ else
20
+ redirect_to prompt_versions_path(@prompt), alert: "Please select two versions to compare"
21
+ end
22
+ end
23
+
24
+ def restore
25
+ ActiveRecord::Base.transaction do
26
+ @prompt.update!(
27
+ content: @version.content,
28
+ system_message: @version.system_message,
29
+ model: @version.model,
30
+ temperature: @version.temperature,
31
+ max_tokens: @version.max_tokens
32
+ )
33
+ end
34
+ redirect_to @prompt, notice: "Prompt restored to version #{@version.version_number}"
35
+ rescue => e
36
+ redirect_to prompt_versions_path(@prompt), alert: "Failed to restore version: #{e.message}"
37
+ end
38
+
39
+ private
40
+
41
+ def set_prompt
42
+ @prompt = Prompt.find(params[:prompt_id])
43
+ end
44
+
45
+ def set_version
46
+ @version = @prompt.versions.find(params[:id])
47
+ end
48
+
49
+ def set_compare_versions
50
+ if params[:version_a_id].present? && params[:version_b_id].present?
51
+ @version_a = @prompt.versions.find_by(id: params[:version_a_id])
52
+ @version_b = @prompt.versions.find_by(id: params[:version_b_id])
53
+ elsif params[:id].present?
54
+ @version_b = @prompt.versions.find(params[:id])
55
+ @version_a = @prompt.versions.where("version_number < ?", @version_b.version_number).order(version_number: :desc).first
56
+ @version_a ||= @version_b
57
+ end
58
+ end
59
+
60
+ def calculate_changes(version_a, version_b)
61
+ {
62
+ content: {
63
+ old: version_a.content,
64
+ new: version_b.content,
65
+ changed: version_a.content != version_b.content
66
+ },
67
+ system_message: {
68
+ old: version_a.system_message,
69
+ new: version_b.system_message,
70
+ changed: version_a.system_message != version_b.system_message
71
+ },
72
+ model: {
73
+ old: version_a.model,
74
+ new: version_b.model,
75
+ changed: version_a.model != version_b.model
76
+ },
77
+ temperature: {
78
+ old: version_a.temperature,
79
+ new: version_b.temperature,
80
+ changed: version_a.temperature != version_b.temperature
81
+ },
82
+ max_tokens: {
83
+ old: version_a.max_tokens,
84
+ new: version_b.max_tokens,
85
+ changed: version_a.max_tokens != version_b.max_tokens
86
+ }
87
+ }
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,4 @@
1
+ module PromptEngine
2
+ module ApplicationHelper
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module PromptEngine
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,6 @@
1
+ module PromptEngine
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ module PromptEngine
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,19 @@
1
+ module PromptEngine
2
+ class EvalResult < ApplicationRecord
3
+ belongs_to :eval_run
4
+ belongs_to :test_case
5
+
6
+ scope :passed, -> { where(passed: true) }
7
+ scope :failed, -> { where(passed: false) }
8
+ scope :by_execution_time, -> { order(:execution_time_ms) }
9
+
10
+ def execution_time_seconds
11
+ return nil unless execution_time_ms
12
+ execution_time_ms / 1000.0
13
+ end
14
+
15
+ def status
16
+ passed? ? "passed" : "failed"
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,40 @@
1
+ module PromptEngine
2
+ class EvalRun < ApplicationRecord
3
+ belongs_to :eval_set
4
+ belongs_to :prompt_version
5
+ has_many :eval_results, dependent: :destroy
6
+
7
+ enum :status, { pending: 0, running: 1, completed: 2, failed: 3 }
8
+
9
+ scope :recent, -> { order(created_at: :desc) }
10
+ scope :by_status, ->(status) { where(status: status) }
11
+ scope :successful, -> { completed.where("passed_count > 0") }
12
+
13
+ def success_rate
14
+ return 0 if total_count.zero?
15
+ (passed_count.to_f / total_count * 100).round(1)
16
+ end
17
+
18
+ def duration
19
+ return nil unless started_at && completed_at
20
+ completed_at - started_at
21
+ end
22
+
23
+ def duration_in_words
24
+ return "Not started" unless started_at
25
+ return "Running" if running?
26
+ return "Failed" if failed? && !completed_at
27
+
28
+ seconds = duration
29
+ return nil unless seconds
30
+
31
+ if seconds < 60
32
+ "#{seconds.round} seconds"
33
+ elsif seconds < 3600
34
+ "#{(seconds / 60).round} minutes"
35
+ else
36
+ "#{(seconds / 3600).round(1)} hours"
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,97 @@
1
+ module PromptEngine
2
+ class EvalSet < ApplicationRecord
3
+ belongs_to :prompt
4
+ has_many :test_cases, dependent: :destroy
5
+ has_many :eval_runs, dependent: :destroy
6
+
7
+ GRADER_TYPES = {
8
+ exact_match: "Exact Match",
9
+ regex: "Regular Expression",
10
+ contains: "Contains Text",
11
+ json_schema: "JSON Match (Exact)"
12
+ }.freeze
13
+
14
+ validates :name, presence: true
15
+ validates :name, uniqueness: { scope: :prompt_id }
16
+ validates :grader_type, presence: true, inclusion: { in: GRADER_TYPES.keys.map(&:to_s) }
17
+
18
+ validate :validate_grader_config
19
+
20
+ scope :by_name, -> { order(:name) }
21
+ scope :with_test_cases, -> { includes(:test_cases) }
22
+
23
+ def latest_run
24
+ eval_runs.recent.first
25
+ end
26
+
27
+ def average_success_rate
28
+ runs_with_results = eval_runs.completed.where("total_count > 0")
29
+ return 0 if runs_with_results.empty?
30
+
31
+ total_passed = runs_with_results.sum(:passed_count)
32
+ total_count = runs_with_results.sum(:total_count)
33
+
34
+ return 0 if total_count.zero?
35
+ (total_passed.to_f / total_count * 100).round(1)
36
+ end
37
+
38
+ def ready_to_run?
39
+ test_cases.any?
40
+ end
41
+
42
+ def grader_type_display
43
+ GRADER_TYPES[grader_type.to_sym] || grader_type.humanize
44
+ end
45
+
46
+ def requires_grader_config?
47
+ %w[regex json_schema].include?(grader_type)
48
+ end
49
+
50
+ private
51
+
52
+ def validate_grader_config
53
+ return unless requires_grader_config?
54
+
55
+ case grader_type
56
+ when "regex"
57
+ validate_regex_config
58
+ when "json_schema"
59
+ validate_json_schema_config
60
+ end
61
+ end
62
+
63
+ def validate_regex_config
64
+ pattern = grader_config["pattern"]
65
+
66
+ if pattern.blank?
67
+ errors.add(:grader_config, "regex pattern is required")
68
+ return
69
+ end
70
+
71
+ begin
72
+ Regexp.new(pattern)
73
+ rescue RegexpError => e
74
+ errors.add(:grader_config, "invalid regex pattern: #{e.message}")
75
+ end
76
+ end
77
+
78
+ def validate_json_schema_config
79
+ schema = grader_config["schema"]
80
+
81
+ if schema.blank?
82
+ errors.add(:grader_config, "JSON schema is required")
83
+ return
84
+ end
85
+
86
+ begin
87
+ JSON.parse(schema.to_json) if schema.is_a?(Hash)
88
+ # Basic schema validation - check for required fields
89
+ unless schema.is_a?(Hash) && schema["type"].present?
90
+ errors.add(:grader_config, "JSON schema must include a 'type' field")
91
+ end
92
+ rescue JSON::ParserError => e
93
+ errors.add(:grader_config, "invalid JSON schema: #{e.message}")
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,126 @@
1
+ module PromptEngine
2
+ class Parameter < ApplicationRecord
3
+ self.table_name = "prompt_engine_parameters"
4
+
5
+ # Parameter types that can be used
6
+ TYPES = %w[string integer decimal boolean datetime date array json].freeze
7
+
8
+ belongs_to :prompt, class_name: "PromptEngine::Prompt"
9
+
10
+ validates :name, presence: true,
11
+ uniqueness: { scope: :prompt_id },
12
+ format: { with: /\A[a-zA-Z_][a-zA-Z0-9_]*\z/, message: "must start with a letter or underscore and contain only letters, numbers, and underscores" }
13
+ validates :parameter_type, presence: true, inclusion: { in: TYPES }
14
+ validates :required, inclusion: { in: [ true, false ] }
15
+
16
+ scope :required, -> { where(required: true) }
17
+ scope :optional, -> { where(required: false) }
18
+ scope :ordered, -> { order(position: :asc, created_at: :asc) }
19
+
20
+ before_validation :set_defaults
21
+
22
+ # Convert the parameter value based on its type
23
+ def cast_value(value)
24
+ return default_value if value.blank? && !required?
25
+
26
+ case parameter_type
27
+ when "integer"
28
+ value.to_i
29
+ when "decimal"
30
+ value.to_f
31
+ when "boolean"
32
+ ActiveModel::Type::Boolean.new.cast(value)
33
+ when "datetime"
34
+ DateTime.parse(value.to_s) rescue nil
35
+ when "date"
36
+ Date.parse(value.to_s) rescue nil
37
+ when "array"
38
+ value.is_a?(Array) ? value : value.to_s.split(",").map(&:strip)
39
+ when "json"
40
+ value.is_a?(String) ? JSON.parse(value) : value rescue {}
41
+ else
42
+ value.to_s
43
+ end
44
+ end
45
+
46
+ # Validate a value against this parameter's rules
47
+ def validate_value(value)
48
+ errors = []
49
+
50
+ if required? && value.blank?
51
+ errors << "#{name} is required"
52
+ end
53
+
54
+ if validation_rules.present?
55
+ # Apply custom validation rules
56
+ if validation_rules["min_length"] && value.to_s.length < validation_rules["min_length"]
57
+ errors << "#{name} must be at least #{validation_rules['min_length']} characters"
58
+ end
59
+
60
+ if validation_rules["max_length"] && value.to_s.length > validation_rules["max_length"]
61
+ errors << "#{name} must be at most #{validation_rules['max_length']} characters"
62
+ end
63
+
64
+ if validation_rules["pattern"] && !value.to_s.match?(Regexp.new(validation_rules["pattern"]))
65
+ errors << "#{name} must match pattern: #{validation_rules['pattern']}"
66
+ end
67
+
68
+ if validation_rules["min"] && cast_value(value) < validation_rules["min"]
69
+ errors << "#{name} must be at least #{validation_rules['min']}"
70
+ end
71
+
72
+ if validation_rules["max"] && cast_value(value) > validation_rules["max"]
73
+ errors << "#{name} must be at most #{validation_rules['max']}"
74
+ end
75
+ end
76
+
77
+ errors
78
+ end
79
+
80
+ # Generate form input attributes for this parameter
81
+ def form_input_options
82
+ options = {
83
+ label: name.humanize,
84
+ required: required?,
85
+ placeholder: example_value,
86
+ hint: description
87
+ }
88
+
89
+ case parameter_type
90
+ when "integer", "decimal"
91
+ options[:type] = "number"
92
+ options[:step] = parameter_type == "decimal" ? "0.01" : "1"
93
+ options[:min] = validation_rules["min"] if validation_rules&.dig("min")
94
+ options[:max] = validation_rules["max"] if validation_rules&.dig("max")
95
+ when "boolean"
96
+ options[:type] = "checkbox"
97
+ when "datetime"
98
+ options[:type] = "datetime-local"
99
+ when "date"
100
+ options[:type] = "date"
101
+ when "array"
102
+ options[:type] = "text"
103
+ options[:hint] = "#{description} (comma-separated values)"
104
+ when "json"
105
+ options[:type] = "textarea"
106
+ options[:hint] = "#{description} (JSON format)"
107
+ else
108
+ options[:type] = "text"
109
+ options[:minlength] = validation_rules["min_length"] if validation_rules&.dig("min_length")
110
+ options[:maxlength] = validation_rules["max_length"] if validation_rules&.dig("max_length")
111
+ options[:pattern] = validation_rules["pattern"] if validation_rules&.dig("pattern")
112
+ end
113
+
114
+ options[:value] = default_value if default_value.present?
115
+
116
+ options
117
+ end
118
+
119
+ private
120
+
121
+ def set_defaults
122
+ self.parameter_type ||= "string"
123
+ self.required = true if required.nil?
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,39 @@
1
+ module PromptEngine
2
+ class ParameterParser
3
+ attr_reader :content
4
+
5
+ def initialize(content)
6
+ @content = content
7
+ end
8
+
9
+ def extract_parameters
10
+ return [] if content.blank?
11
+
12
+ parameter_names = content.scan(/\{\{([^}]+)\}\}/).flatten.map(&:strip).uniq
13
+
14
+ parameter_names.map do |name|
15
+ {
16
+ name: name,
17
+ placeholder: "{{#{name}}}",
18
+ required: true
19
+ }
20
+ end
21
+ end
22
+
23
+ def replace_parameters(parameters = {})
24
+ result = content.dup
25
+
26
+ return result if parameters.nil?
27
+
28
+ parameters.each do |key, value|
29
+ result.gsub!("{{#{key}}}", value.to_s)
30
+ end
31
+
32
+ result
33
+ end
34
+
35
+ def has_parameters?
36
+ extract_parameters.any?
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,20 @@
1
+ module PromptEngine
2
+ class PlaygroundRunResult < ApplicationRecord
3
+ self.table_name = "prompt_engine_playground_run_results"
4
+
5
+ belongs_to :prompt_version, class_name: "PromptEngine::PromptVersion"
6
+
7
+ validates :provider, presence: true
8
+ validates :model, presence: true
9
+ validates :rendered_prompt, presence: true
10
+ validates :response, presence: true
11
+ validates :execution_time, presence: true, numericality: { greater_than_or_equal_to: 0 }
12
+ validates :token_count, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
13
+
14
+ serialize :parameters, coder: JSON
15
+
16
+ scope :recent, -> { order(created_at: :desc) }
17
+ scope :by_provider, ->(provider) { where(provider: provider) }
18
+ scope :successful, -> { where.not(response: nil) }
19
+ end
20
+ end
@@ -0,0 +1,192 @@
1
+ module PromptEngine
2
+ class Prompt < ApplicationRecord
3
+ self.table_name = "prompt_engine_prompts"
4
+
5
+ has_many :versions, -> { order(version_number: :desc) },
6
+ class_name: "PromptEngine::PromptVersion",
7
+ dependent: :destroy
8
+ has_many :parameters, -> { ordered },
9
+ class_name: "PromptEngine::Parameter",
10
+ dependent: :destroy
11
+ has_many :eval_sets,
12
+ class_name: "PromptEngine::EvalSet",
13
+ dependent: :destroy
14
+
15
+ attr_accessor :change_summary
16
+
17
+ validates :name, presence: true, uniqueness: { scope: :status }
18
+ validates :content, presence: true
19
+
20
+ accepts_nested_attributes_for :parameters, allow_destroy: true
21
+
22
+ enum :status, {
23
+ draft: "draft",
24
+ active: "active",
25
+ archived: "archived"
26
+ }, default: "draft"
27
+
28
+ scope :active, -> { where(status: "active") }
29
+ scope :by_name, -> { order(:name) }
30
+
31
+ after_create :create_initial_version
32
+ after_create :sync_parameters!
33
+ after_update :create_version_if_changed
34
+ after_update :sync_parameters!, if: :saved_change_to_content?
35
+ before_save :clean_orphaned_parameters
36
+
37
+ VERSIONED_ATTRIBUTES = %w[content system_message model temperature max_tokens metadata].freeze
38
+
39
+ def current_version
40
+ versions.first
41
+ end
42
+
43
+ def version_count
44
+ versions_count
45
+ end
46
+
47
+ def restore_version!(version_number)
48
+ version = versions.find_by!(version_number: version_number)
49
+ version.restore!
50
+ end
51
+
52
+ def version_at(version_number)
53
+ versions.find_by(version_number: version_number)
54
+ end
55
+
56
+ def versioned_attributes_changed?
57
+ # This method is for checking if versioned attributes have changed before save
58
+ (changed & VERSIONED_ATTRIBUTES).any?
59
+ end
60
+
61
+ # Parameter management methods
62
+ def detect_variables
63
+ # Don't cache the detector as content can change
64
+ PromptEngine::VariableDetector.new(content).variable_names
65
+ end
66
+
67
+ def sync_parameters!
68
+ detected_vars = detect_variables
69
+ existing_names = parameters.pluck(:name)
70
+
71
+ # Add new parameters
72
+ new_vars = detected_vars - existing_names
73
+ if new_vars.any?
74
+ # Get max position once, before the loop
75
+ max_position = parameters.maximum(:position) || 0
76
+ detector = PromptEngine::VariableDetector.new(content)
77
+
78
+ new_vars.each_with_index do |var_name, index|
79
+ var_info = detector.extract_variables.find { |v| v[:name] == var_name }
80
+
81
+ # Skip if parameter already exists (race condition protection)
82
+ next if parameters.exists?(name: var_name)
83
+
84
+ parameters.create!(
85
+ name: var_name,
86
+ parameter_type: var_info[:type],
87
+ required: var_info[:required],
88
+ position: max_position + index + 1
89
+ )
90
+ end
91
+ end
92
+
93
+ # Remove parameters that no longer exist
94
+ removed_vars = existing_names - detected_vars
95
+ parameters.where(name: removed_vars).destroy_all if removed_vars.any?
96
+
97
+ true
98
+ end
99
+
100
+ def render_with_params(provided_params = {})
101
+ detector = PromptEngine::VariableDetector.new(content)
102
+
103
+ # Validate all required parameters are provided
104
+ validation = validate_parameters(provided_params)
105
+ return { error: validation[:errors].join(", ") } unless validation[:valid]
106
+
107
+ # Cast parameters to their correct types, including defaults
108
+ casted_params = {}
109
+ parameters.each do |param|
110
+ value = provided_params[param.name] || provided_params[param.name.to_sym]
111
+ # Let cast_value handle the default value logic
112
+ casted_params[param.name] = param.cast_value(value)
113
+ end
114
+
115
+ # Also include any parameters not defined in the database but present in the template
116
+ detected_vars = detect_variables
117
+ detected_vars.each do |var_name|
118
+ unless casted_params.key?(var_name)
119
+ value = provided_params[var_name] || provided_params[var_name.to_sym]
120
+ casted_params[var_name] = value.to_s if value.present?
121
+ end
122
+ end
123
+
124
+ {
125
+ content: detector.render(casted_params),
126
+ system_message: system_message,
127
+ model: model,
128
+ temperature: temperature,
129
+ max_tokens: max_tokens,
130
+ parameters_used: casted_params
131
+ }
132
+ end
133
+
134
+ def validate_parameters(provided_params = {})
135
+ errors = []
136
+
137
+ parameters.each do |param|
138
+ value = provided_params[param.name] || provided_params[param.name.to_sym]
139
+ # Use default value if not provided and parameter is optional
140
+ if value.blank? && !param.required? && param.default_value.present?
141
+ value = param.default_value
142
+ end
143
+ param_errors = param.validate_value(value)
144
+ errors.concat(param_errors)
145
+ end
146
+
147
+ {
148
+ valid: errors.empty?,
149
+ errors: errors
150
+ }
151
+ end
152
+
153
+ private
154
+
155
+ def create_initial_version
156
+ versions.create!(
157
+ content: content,
158
+ system_message: system_message,
159
+ model: model,
160
+ temperature: temperature,
161
+ max_tokens: max_tokens,
162
+ metadata: metadata,
163
+ change_description: "Initial version"
164
+ )
165
+ end
166
+
167
+ def create_version_if_changed
168
+ # Check saved_changes in the after_update callback
169
+ return unless (saved_changes.keys & VERSIONED_ATTRIBUTES).any?
170
+
171
+ versions.create!(
172
+ content: content,
173
+ system_message: system_message,
174
+ model: model,
175
+ temperature: temperature,
176
+ max_tokens: max_tokens,
177
+ metadata: metadata,
178
+ change_description: "Updated: #{(saved_changes.keys & VERSIONED_ATTRIBUTES).join(', ')}"
179
+ )
180
+ end
181
+
182
+ def clean_orphaned_parameters
183
+ return unless content_changed?
184
+
185
+ # Mark parameters for destruction if their names are not in the content
186
+ detected_vars = detect_variables
187
+ parameters.each do |param|
188
+ param.mark_for_destruction unless detected_vars.include?(param.name)
189
+ end
190
+ end
191
+ end
192
+ end