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,200 @@
1
+ module PromptEngine
2
+ class EvalSetsController < ApplicationController
3
+ layout "prompt_engine/admin"
4
+
5
+ before_action :set_prompt
6
+ before_action :set_eval_set, only: [ :show, :edit, :update, :destroy, :run, :compare, :metrics ]
7
+
8
+ def index
9
+ @eval_sets = @prompt.eval_sets
10
+ end
11
+
12
+ def show
13
+ @test_cases = @eval_set.test_cases
14
+ @recent_runs = @eval_set.eval_runs.order(created_at: :desc).limit(5)
15
+ end
16
+
17
+ def new
18
+ @eval_set = @prompt.eval_sets.build
19
+ end
20
+
21
+ def create
22
+ @eval_set = @prompt.eval_sets.build(eval_set_params)
23
+
24
+ if @eval_set.save
25
+ redirect_to prompt_eval_set_path(@prompt, @eval_set), notice: "Evaluation set was successfully created."
26
+ else
27
+ flash.now[:alert] = "Please fix the errors below."
28
+ render :new
29
+ end
30
+ end
31
+
32
+ def edit
33
+ end
34
+
35
+ def update
36
+ if @eval_set.update(eval_set_params)
37
+ redirect_to prompt_eval_set_path(@prompt, @eval_set), notice: "Evaluation set was successfully updated."
38
+ else
39
+ flash.now[:alert] = "Please fix the errors below."
40
+ render :edit
41
+ end
42
+ end
43
+
44
+ def destroy
45
+ @eval_set.destroy
46
+ redirect_to prompt_eval_sets_path(@prompt), notice: "Evaluation set was successfully deleted."
47
+ end
48
+
49
+ def run
50
+ # Check if API key is available
51
+ unless api_key_configured?
52
+ redirect_to prompt_eval_set_path(@prompt, @eval_set),
53
+ alert: "OpenAI API key not configured. Please configure it in Settings or contact your administrator."
54
+ return
55
+ end
56
+
57
+ # Create new eval run with current prompt version
58
+ @eval_run = @eval_set.eval_runs.create!(
59
+ prompt_version: @prompt.current_version
60
+ )
61
+
62
+ begin
63
+ # Run evaluation synchronously for MVP
64
+ PromptEngine::EvaluationRunner.new(@eval_run).execute
65
+ redirect_to prompt_eval_run_path(@prompt, @eval_run), notice: "Evaluation started successfully"
66
+ rescue PromptEngine::OpenAiEvalsClient::AuthenticationError => e
67
+ @eval_run.update!(status: :failed, error_message: e.message)
68
+ redirect_to prompt_eval_set_path(@prompt, @eval_set),
69
+ alert: "Authentication failed: Please check your OpenAI API key in Settings"
70
+ rescue PromptEngine::OpenAiEvalsClient::RateLimitError => e
71
+ @eval_run.update!(status: :failed, error_message: e.message)
72
+ redirect_to prompt_eval_set_path(@prompt, @eval_set), alert: "Rate limit exceeded: Please try again later"
73
+ rescue PromptEngine::OpenAiEvalsClient::APIError => e
74
+ @eval_run.update!(status: :failed, error_message: e.message)
75
+ redirect_to prompt_eval_set_path(@prompt, @eval_set), alert: "API error: #{e.message}"
76
+ rescue => e
77
+ @eval_run.update!(status: :failed, error_message: e.message)
78
+ Rails.logger.error "Evaluation error: #{e.class} - #{e.message}"
79
+ Rails.logger.error e.backtrace.join("\n")
80
+ redirect_to prompt_eval_set_path(@prompt, @eval_set), alert: "Evaluation failed: #{e.message}"
81
+ end
82
+ end
83
+
84
+ def compare
85
+ unless params[:run_ids].present? && params[:run_ids].is_a?(Array) && params[:run_ids].length == 2
86
+ redirect_to prompt_eval_set_path(@prompt, @eval_set),
87
+ alert: "Please select exactly two evaluation runs to compare."
88
+ return
89
+ end
90
+
91
+ @run1 = @eval_set.eval_runs.find(params[:run_ids][0])
92
+ @run2 = @eval_set.eval_runs.find(params[:run_ids][1])
93
+
94
+ # Ensure both runs are completed
95
+ unless @run1.status == "completed" && @run2.status == "completed"
96
+ redirect_to prompt_eval_set_path(@prompt, @eval_set),
97
+ alert: "Both evaluation runs must be completed to compare them."
98
+ return
99
+ end
100
+
101
+ # Calculate comparison metrics
102
+ @run1_success_rate = (@run1.total_count > 0) ? (@run1.passed_count.to_f / @run1.total_count * 100) : 0
103
+ @run2_success_rate = (@run2.total_count > 0) ? (@run2.passed_count.to_f / @run2.total_count * 100) : 0
104
+ @success_rate_diff = @run2_success_rate - @run1_success_rate
105
+ rescue ActiveRecord::RecordNotFound
106
+ redirect_to prompt_eval_set_path(@prompt, @eval_set),
107
+ alert: "One or both evaluation runs could not be found."
108
+ end
109
+
110
+ def metrics
111
+ # Get all completed runs for this eval set
112
+ @eval_runs = @eval_set.eval_runs.where(status: "completed").order(created_at: :asc)
113
+
114
+ # Calculate metrics data for charts
115
+ if @eval_runs.any?
116
+ # Success rate trend data (for line chart)
117
+ @success_rate_trend = @eval_runs.map do |run|
118
+ {
119
+ date: run.created_at.strftime("%b %d, %Y %I:%M %p"),
120
+ rate: (run.total_count > 0) ? (run.passed_count.to_f / run.total_count * 100).round(2) : 0,
121
+ version: "v#{run.prompt_version.version_number}"
122
+ }
123
+ end
124
+
125
+ # Success rate by version (for bar chart)
126
+ version_stats = @eval_runs.group_by { |r| r.prompt_version.version_number }
127
+ @success_rate_by_version = version_stats.map do |version, runs|
128
+ total_passed = runs.sum(&:passed_count)
129
+ total_count = runs.sum(&:total_count)
130
+ {
131
+ version: "v#{version}",
132
+ rate: (total_count > 0) ? (total_passed.to_f / total_count * 100).round(2) : 0,
133
+ runs: runs.count
134
+ }
135
+ end.sort_by { |v| v[:version] }
136
+
137
+ # Test case statistics
138
+ @total_test_cases = @eval_set.test_cases.count
139
+ @total_runs = @eval_runs.count
140
+ @overall_pass_rate = begin
141
+ total_passed = @eval_runs.sum(&:passed_count)
142
+ total_tests = @eval_runs.sum(&:total_count)
143
+ (total_tests > 0) ? (total_passed.to_f / total_tests * 100).round(2) : 0
144
+ end
145
+
146
+ # Average duration trend
147
+ @duration_trend = @eval_runs.map do |run|
148
+ duration = if run.completed_at && run.started_at
149
+ (run.completed_at - run.started_at).to_i
150
+ else
151
+ nil
152
+ end
153
+ {
154
+ date: run.created_at.strftime("%b %d, %Y %I:%M %p"),
155
+ duration: duration,
156
+ version: "v#{run.prompt_version.version_number}"
157
+ }
158
+ end.compact
159
+
160
+ # Recent activity (last 10 runs)
161
+ @recent_activity = @eval_runs.last(10).reverse
162
+ else
163
+ @success_rate_trend = []
164
+ @success_rate_by_version = []
165
+ @total_test_cases = @eval_set.test_cases.count
166
+ @total_runs = 0
167
+ @overall_pass_rate = 0
168
+ @duration_trend = []
169
+ @recent_activity = []
170
+ end
171
+ end
172
+
173
+ protected
174
+
175
+ helper_method :api_key_configured?
176
+
177
+ private
178
+
179
+ def set_prompt
180
+ @prompt = Prompt.find(params[:prompt_id])
181
+ end
182
+
183
+ def set_eval_set
184
+ @eval_set = @prompt.eval_sets.find(params[:id])
185
+ end
186
+
187
+ def eval_set_params
188
+ params.require(:eval_set).permit(:name, :description, :grader_type, grader_config: {})
189
+ end
190
+
191
+ def api_key_configured?
192
+ # Check if OpenAI API key is available from Settings or Rails credentials
193
+ settings = PromptEngine::Setting.instance
194
+ settings.openai_configured? || Rails.application.credentials.dig(:openai, :api_key).present?
195
+ rescue ActiveRecord::RecordNotFound
196
+ # If settings record doesn't exist, check Rails credentials
197
+ Rails.application.credentials.dig(:openai, :api_key).present?
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,32 @@
1
+ module PromptEngine
2
+ class EvaluationsController < ApplicationController
3
+ layout "prompt_engine/admin"
4
+
5
+ def index
6
+ @prompts_with_eval_sets = Prompt.joins(:eval_sets)
7
+ .includes(eval_sets: [ :eval_runs ])
8
+ .distinct
9
+ .order(:name)
10
+
11
+ # Calculate overall statistics
12
+ @total_eval_sets = EvalSet.count
13
+ @total_eval_runs = EvalRun.count
14
+ @total_test_cases = TestCase.count
15
+
16
+ # Get recent evaluation activity
17
+ @recent_runs = EvalRun.includes(eval_set: :prompt, prompt_version: :prompt)
18
+ .order(created_at: :desc)
19
+ .limit(10)
20
+
21
+ # Calculate overall pass rate
22
+ completed_runs = EvalRun.where(status: "completed")
23
+ if completed_runs.any?
24
+ total_passed = completed_runs.sum(:passed_count)
25
+ total_tests = completed_runs.sum(:total_count)
26
+ @overall_pass_rate = (total_tests > 0) ? (total_passed.to_f / total_tests * 100).round(2) : 0
27
+ else
28
+ @overall_pass_rate = 0
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,57 @@
1
+ module PromptEngine
2
+ class PlaygroundController < ApplicationController
3
+ layout "prompt_engine/admin"
4
+ before_action :set_prompt
5
+
6
+ def show
7
+ @parameters = ParameterParser.new(@prompt.content).extract_parameters.map { |p| p[:name] }
8
+ @settings = Setting.instance
9
+ end
10
+
11
+ def execute
12
+ executor = PlaygroundExecutor.new(
13
+ prompt: @prompt,
14
+ provider: params[:provider],
15
+ api_key: params[:api_key],
16
+ parameters: params[:parameters]
17
+ )
18
+
19
+ begin
20
+ result = executor.execute
21
+ @response = result[:response]
22
+ @execution_time = result[:execution_time]
23
+ @token_count = result[:token_count]
24
+ @model = result[:model]
25
+ @provider = result[:provider]
26
+
27
+ # Store the rendered prompt for display
28
+ parser = ParameterParser.new(@prompt.content)
29
+ @rendered_prompt = parser.replace_parameters(params[:parameters])
30
+
31
+ # Save the playground run result
32
+ @prompt.current_version.playground_run_results.create!(
33
+ provider: @provider,
34
+ model: @model,
35
+ rendered_prompt: @rendered_prompt,
36
+ system_message: @prompt.system_message,
37
+ parameters: params[:parameters],
38
+ response: @response,
39
+ execution_time: @execution_time,
40
+ token_count: @token_count,
41
+ temperature: @prompt.temperature,
42
+ max_tokens: @prompt.max_tokens
43
+ )
44
+ rescue => e
45
+ @error = e.message
46
+ end
47
+
48
+ render :result
49
+ end
50
+
51
+ private
52
+
53
+ def set_prompt
54
+ @prompt = Prompt.find(params[:id])
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,41 @@
1
+ module PromptEngine
2
+ class PlaygroundRunResultsController < ApplicationController
3
+ layout "prompt_engine/admin"
4
+ before_action :set_playground_run_result, only: [ :show ]
5
+ before_action :set_context, only: [ :index ]
6
+
7
+ def index
8
+ @playground_run_results = scope.recent.includes(prompt_version: :prompt)
9
+ end
10
+
11
+ def show
12
+ end
13
+
14
+ private
15
+
16
+ def set_playground_run_result
17
+ @playground_run_result = PlaygroundRunResult.find(params[:id])
18
+ end
19
+
20
+ def set_context
21
+ if params[:prompt_id]
22
+ @prompt = Prompt.find(params[:prompt_id])
23
+ elsif params[:version_id]
24
+ @prompt_version = PromptVersion.find(params[:version_id])
25
+ @prompt = @prompt_version.prompt
26
+ end
27
+ end
28
+
29
+ def scope
30
+ if params[:prompt_id]
31
+ # Get all playground run results for all versions of this prompt
32
+ PlaygroundRunResult.joins(:prompt_version).where(prompt_engine_prompt_versions: { prompt_id: params[:prompt_id] })
33
+ elsif params[:version_id]
34
+ # Get playground run results for a specific version
35
+ PromptVersion.find(params[:version_id]).playground_run_results
36
+ else
37
+ PlaygroundRunResult.all
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,70 @@
1
+ module PromptEngine
2
+ class PromptsController < ApplicationController
3
+ layout "prompt_engine/admin"
4
+ before_action :set_prompt, only: [ :show, :edit, :update, :destroy ]
5
+
6
+ def index
7
+ @prompts = PromptEngine::Prompt.by_name
8
+ end
9
+
10
+ def show
11
+ # Get recent test runs for this prompt across all versions
12
+ @recent_test_runs = PromptEngine::PlaygroundRunResult
13
+ .joins(:prompt_version)
14
+ .where(prompt_engine_prompt_versions: { prompt_id: @prompt.id })
15
+ .recent
16
+ .limit(5)
17
+ .includes(:prompt_version)
18
+
19
+ # Get evaluation data for this prompt
20
+ @eval_sets = @prompt.eval_sets.includes(:test_cases, :eval_runs)
21
+ @recent_eval_runs = PromptEngine::EvalRun
22
+ .joins(:eval_set)
23
+ .where(prompt_engine_eval_sets: { prompt_id: @prompt.id })
24
+ .order(created_at: :desc)
25
+ .limit(5)
26
+ .includes(:eval_set, :prompt_version)
27
+ end
28
+
29
+ def new
30
+ @prompt = PromptEngine::Prompt.new
31
+ end
32
+
33
+ def create
34
+ @prompt = PromptEngine::Prompt.new(prompt_params)
35
+
36
+ if @prompt.save
37
+ redirect_to prompt_path(@prompt), notice: "Prompt was successfully created."
38
+ else
39
+ render :new, status: :unprocessable_entity
40
+ end
41
+ end
42
+
43
+ def edit
44
+ end
45
+
46
+ def update
47
+ if @prompt.update(prompt_params)
48
+ redirect_to prompt_path(@prompt), notice: "Prompt was successfully updated."
49
+ else
50
+ render :edit, status: :unprocessable_entity
51
+ end
52
+ end
53
+
54
+ def destroy
55
+ @prompt.destroy
56
+ redirect_to prompts_path, notice: "Prompt was successfully deleted."
57
+ end
58
+
59
+ private
60
+
61
+ def set_prompt
62
+ @prompt = PromptEngine::Prompt.find(params[:id])
63
+ end
64
+
65
+ def prompt_params
66
+ params.require(:prompt).permit(:name, :description, :content, :system_message, :model, :temperature, :max_tokens, :status,
67
+ parameters_attributes: [ :id, :name, :description, :parameter_type, :required, :default_value, :_destroy ])
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,28 @@
1
+ module PromptEngine
2
+ class SettingsController < ApplicationController
3
+ layout "prompt_engine/admin"
4
+ before_action :load_settings
5
+
6
+ def edit
7
+ # Just display the form
8
+ end
9
+
10
+ def update
11
+ if @settings.update(settings_params)
12
+ redirect_to edit_settings_path, notice: "Settings have been updated successfully."
13
+ else
14
+ render :edit, status: :unprocessable_entity
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def load_settings
21
+ @settings = Setting.instance
22
+ end
23
+
24
+ def settings_params
25
+ params.require(:setting).permit(:openai_api_key, :anthropic_api_key)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,231 @@
1
+ module PromptEngine
2
+ class TestCasesController < ApplicationController
3
+ layout "prompt_engine/admin"
4
+
5
+ before_action :set_prompt
6
+ before_action :set_eval_set
7
+ before_action :set_test_case, only: [ :edit, :update, :destroy ]
8
+
9
+ def new
10
+ @test_case = @eval_set.test_cases.build
11
+ # Pre-populate with prompt's parameters
12
+ @test_case.input_variables = @prompt.parameters.each_with_object({}) do |param, hash|
13
+ hash[param.name] = param.default_value
14
+ end
15
+ end
16
+
17
+ def create
18
+ @test_case = @eval_set.test_cases.build(test_case_params)
19
+
20
+ if @test_case.save
21
+ redirect_to prompt_eval_set_path(@prompt, @eval_set), notice: "Test case was successfully created."
22
+ else
23
+ flash.now[:alert] = "Please fix the errors below."
24
+ render :new
25
+ end
26
+ end
27
+
28
+ def edit
29
+ end
30
+
31
+ def update
32
+ if @test_case.update(test_case_params)
33
+ redirect_to prompt_eval_set_path(@prompt, @eval_set), notice: "Test case was successfully updated."
34
+ else
35
+ flash.now[:alert] = "Please fix the errors below."
36
+ render :edit
37
+ end
38
+ end
39
+
40
+ def destroy
41
+ @test_case.destroy
42
+ redirect_to prompt_eval_set_path(@prompt, @eval_set), notice: "Test case was successfully deleted."
43
+ end
44
+
45
+ def import
46
+ # Display the import form
47
+ end
48
+
49
+ def import_preview
50
+ unless params[:file].present?
51
+ redirect_to import_prompt_eval_set_test_cases_path(@prompt, @eval_set),
52
+ alert: "Please select a file to import."
53
+ return
54
+ end
55
+
56
+ @imported_data = []
57
+ @errors = []
58
+
59
+ begin
60
+ file_content = params[:file].read
61
+ file_type = detect_file_type(params[:file])
62
+
63
+ if file_type == :csv
64
+ parse_csv(file_content)
65
+ elsif file_type == :json
66
+ parse_json(file_content)
67
+ else
68
+ @errors << "Unsupported file format. Please upload a CSV or JSON file."
69
+ end
70
+ rescue => e
71
+ @errors << "Error reading file: #{e.message}"
72
+ end
73
+
74
+ if @errors.any?
75
+ flash.now[:alert] = @errors.join(", ")
76
+ render :import
77
+ else
78
+ # Store the imported data in session for the create action
79
+ session[:imported_test_cases] = @imported_data
80
+ render :import_preview
81
+ end
82
+ end
83
+
84
+ def import_create
85
+ imported_data = session[:imported_test_cases]
86
+
87
+ unless imported_data.present?
88
+ redirect_to import_prompt_eval_set_test_cases_path(@prompt, @eval_set),
89
+ alert: "No import data found. Please upload a file again."
90
+ return
91
+ end
92
+
93
+ success_count = 0
94
+ errors = []
95
+
96
+ imported_data.each_with_index do |data, index|
97
+ test_case = @eval_set.test_cases.build(
98
+ input_variables: data[:input_variables],
99
+ expected_output: data[:expected_output],
100
+ description: data[:description]
101
+ )
102
+
103
+ if test_case.save
104
+ success_count += 1
105
+ else
106
+ errors << "Row #{index + 1}: #{test_case.errors.full_messages.join(", ")}"
107
+ end
108
+ end
109
+
110
+ # Clear the session data
111
+ session.delete(:imported_test_cases)
112
+
113
+ if errors.any?
114
+ flash[:alert] = "Import completed with errors: #{errors.join("; ")}"
115
+ else
116
+ flash[:notice] = "Successfully imported #{success_count} test cases."
117
+ end
118
+
119
+ redirect_to prompt_eval_set_path(@prompt, @eval_set)
120
+ end
121
+
122
+ private
123
+
124
+ def set_prompt
125
+ @prompt = Prompt.find(params[:prompt_id])
126
+ end
127
+
128
+ def set_eval_set
129
+ @eval_set = @prompt.eval_sets.find(params[:eval_set_id])
130
+ end
131
+
132
+ def set_test_case
133
+ @test_case = @eval_set.test_cases.find(params[:id])
134
+ end
135
+
136
+ def test_case_params
137
+ params.require(:test_case).permit(:description, :expected_output, input_variables: {})
138
+ end
139
+
140
+ def detect_file_type(file)
141
+ filename = file.original_filename.downcase
142
+
143
+ if filename.ends_with?(".csv")
144
+ :csv
145
+ elsif filename.ends_with?(".json")
146
+ :json
147
+ else
148
+ :unknown
149
+ end
150
+ end
151
+
152
+ def parse_csv(content)
153
+ require "csv"
154
+
155
+ # Get prompt parameters for column validation
156
+ expected_params = @prompt.parameters.pluck(:name)
157
+
158
+ CSV.parse(content, headers: true) do |row|
159
+ # Extract input variables from prompt parameter columns
160
+ input_variables = {}
161
+
162
+ expected_params.each do |param_name|
163
+ if row.headers.include?(param_name)
164
+ input_variables[param_name] = row[param_name]
165
+ else
166
+ @errors << "Missing required column: #{param_name}"
167
+ return
168
+ end
169
+ end
170
+
171
+ # Check for expected_output column
172
+ unless row.headers.include?("expected_output")
173
+ @errors << "Missing required column: expected_output"
174
+ return
175
+ end
176
+
177
+ # Add to imported data
178
+ @imported_data << {
179
+ input_variables: input_variables,
180
+ expected_output: row["expected_output"],
181
+ description: row["description"] # Optional column
182
+ }
183
+ end
184
+ rescue CSV::MalformedCSVError => e
185
+ @errors << "Invalid CSV format: #{e.message}"
186
+ end
187
+
188
+ def parse_json(content)
189
+ data = JSON.parse(content)
190
+
191
+ unless data.is_a?(Array)
192
+ @errors << "JSON must be an array of objects"
193
+ return
194
+ end
195
+
196
+ expected_params = @prompt.parameters.pluck(:name)
197
+
198
+ data.each_with_index do |item, index|
199
+ unless item.is_a?(Hash)
200
+ @errors << "Item #{index + 1} must be an object"
201
+ next
202
+ end
203
+
204
+ unless item["input_variables"].is_a?(Hash)
205
+ @errors << "Item #{index + 1}: input_variables must be an object"
206
+ next
207
+ end
208
+
209
+ unless item["expected_output"].present?
210
+ @errors << "Item #{index + 1}: expected_output is required"
211
+ next
212
+ end
213
+
214
+ # Validate that all required parameters are present
215
+ missing_params = expected_params - item["input_variables"].keys
216
+ if missing_params.any?
217
+ @errors << "Item #{index + 1}: missing required parameters: #{missing_params.join(", ")}"
218
+ next
219
+ end
220
+
221
+ @imported_data << {
222
+ input_variables: item["input_variables"],
223
+ expected_output: item["expected_output"],
224
+ description: item["description"]
225
+ }
226
+ end
227
+ rescue JSON::ParserError => e
228
+ @errors << "Invalid JSON format: #{e.message}"
229
+ end
230
+ end
231
+ end