leva 0.3.1 → 0.3.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '03694d16308b610d8c1cc83ec070cf2c0a03273d93b4e220834ff063f8df5b0a'
4
- data.tar.gz: 31fa8e5737410dbb9b5729bf43616ef037fbad1c6b8188e60649a5156c8f87c1
3
+ metadata.gz: df2ad9b893851f23ca75b7f498279ff86b8687fa38d783bc5d33001327cd4928
4
+ data.tar.gz: 920b07a223df9e0f89f6e7f56d81270f12749b2afc9d47817d70069e17bab582
5
5
  SHA512:
6
- metadata.gz: f12f9ec8d00a5dcd9a8c003a598d9ec316be4bd8b8b2deb7a99680a14dcd64790b496829e7635e28f5b86dd7a5f484b9043b504bda24f7e3d0fd75b8e4eee271
7
- data.tar.gz: 293f53edc39d95ed612b0ce0e0e5097f38e888990c7e8530b54da6afcf2015ae7f150f8f9bd9d2bb1171c5bf18c0c4a34180482594c376ed17341ae42bce9f09
6
+ metadata.gz: 0c43f558d5e096512c90541d13d6dcc1d2d0cf4d08801aaf396829c0bf4f9c8ad3a7bd7d82a582efa55a99719fb54d70af44d5124ce3bd9c62affc9d2f2b69fe
7
+ data.tar.gz: 8880059ab2e141c2091fe0b2fc18581316dbc770109c1957e081a85413882e736373d9c00d791bab10b896e824ca68dae7bc02e72286fb8e7eb6bb5c16bf1a6c
data/README.md CHANGED
@@ -81,6 +81,14 @@ class TextContent < ApplicationRecord
81
81
  created_at: created_at.strftime('%Y-%m-%d %H:%M:%S')
82
82
  }
83
83
  end
84
+
85
+ # Optional: Override for DSPy optimization (falls back to to_llm_context if not defined).
86
+ # Use this to provide a simplified context with only the fields needed for optimization.
87
+ # All values must be strings (nil values are automatically converted to empty strings).
88
+ # @return [Hash<Symbol, String>] Context hash for DSPy optimization
89
+ def to_dspy_context
90
+ { text: text }
91
+ end
84
92
  end
85
93
 
86
94
  dataset = Leva::Dataset.create(name: "Sentiment Analysis Dataset")
@@ -195,10 +203,13 @@ Add the DSPy gems to your Gemfile:
195
203
 
196
204
  ```ruby
197
205
  gem "dspy" # Core DSPy functionality (required)
206
+ gem "dspy-ruby_llm" # RubyLLM provider adapter (required)
198
207
  gem "dspy-gepa" # GEPA optimizer (optional, recommended)
199
208
  gem "dspy-miprov2" # MIPROv2 optimizer (optional)
200
209
  ```
201
210
 
211
+ You can use any DSPy provider adapter instead of `dspy-ruby_llm`, such as `dspy-openai` or `dspy-anthropic`.
212
+
202
213
  ### Available Optimizers
203
214
 
204
215
  | Optimizer | Best For | Description |
@@ -215,7 +226,7 @@ optimizer = Leva::PromptOptimizer.new(
215
226
  dataset: dataset,
216
227
  optimizer: :gepa, # :bootstrap, :gepa, or :miprov2
217
228
  mode: :medium, # :light, :medium, or :heavy
218
- model: "gpt-4o-mini" # Any model supported by RubyLLM
229
+ model: "claude-opus-4-5" # Any model supported by RubyLLM
219
230
  )
220
231
 
221
232
  # Run optimization
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Leva
4
+ class OptimizationRunsController < ApplicationController
5
+ before_action :set_optimization_run
6
+
7
+ # GET /optimization_runs/:id
8
+ # Shows the optimization progress page
9
+ # @return [void]
10
+ def show
11
+ respond_to do |format|
12
+ format.html
13
+ format.json { render json: @optimization_run }
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ # @return [void]
20
+ def set_optimization_run
21
+ @optimization_run = OptimizationRun.find(params[:id])
22
+ end
23
+ end
24
+ end
@@ -1,5 +1,26 @@
1
1
  module Leva
2
2
  module ApplicationHelper
3
+ # Returns the status of an optimization step.
4
+ #
5
+ # @param optimization_run [Leva::OptimizationRun] The optimization run
6
+ # @param step_key [String] The step key to check
7
+ # @return [String] 'completed', 'active', or 'pending'
8
+ def optimization_step_status(optimization_run, step_key)
9
+ steps = Leva::OptimizationRun::STEPS.keys
10
+ current_index = steps.index(optimization_run.current_step) || -1
11
+ step_index = steps.index(step_key)
12
+
13
+ return "pending" if step_index.nil?
14
+
15
+ if optimization_run.completed? || step_index < current_index
16
+ "completed"
17
+ elsif step_index == current_index
18
+ "active"
19
+ else
20
+ "pending"
21
+ end
22
+ end
23
+
3
24
  # Loads all evaluator classes that inherit from Leva::BaseEval
4
25
  #
5
26
  # @return [Array<Class>] An array of evaluator classes
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Leva
4
+ # Background job for running prompt optimization with progress tracking.
5
+ #
6
+ # This job executes the optimization process asynchronously, updating
7
+ # the OptimizationRun record with progress for live UI updates.
8
+ #
9
+ # @example Enqueue an optimization job
10
+ # run = OptimizationRun.create!(dataset: dataset, prompt_name: "My Prompt", mode: :light)
11
+ # Leva::PromptOptimizationJob.perform_later(optimization_run_id: run.id)
12
+ class PromptOptimizationJob < ApplicationJob
13
+ queue_as :default
14
+
15
+ # Performs the prompt optimization and creates a new Prompt.
16
+ #
17
+ # @param optimization_run_id [Integer] The ID of the OptimizationRun to process
18
+ # @return [Leva::Prompt] The created optimized prompt
19
+ def perform(optimization_run_id:)
20
+ @run = OptimizationRun.find(optimization_run_id)
21
+ @run.start!
22
+
23
+ dataset = @run.dataset
24
+
25
+ optimizer = PromptOptimizer.new(
26
+ dataset: dataset,
27
+ mode: @run.mode.to_sym,
28
+ model: @run.model,
29
+ optimizer: @run.optimizer.to_sym,
30
+ progress_callback: method(:update_progress)
31
+ )
32
+
33
+ result = optimizer.optimize
34
+
35
+ ActiveRecord::Base.transaction do
36
+ prompt = Prompt.create!(
37
+ name: @run.prompt_name,
38
+ system_prompt: result[:system_prompt],
39
+ user_prompt: result[:user_prompt],
40
+ metadata: result[:metadata]
41
+ )
42
+
43
+ @run.complete!(prompt)
44
+ prompt
45
+ end
46
+ rescue ActiveRecord::RecordNotFound => e
47
+ Rails.logger.error "[Leva::PromptOptimizationJob] OptimizationRun not found: #{e.message}"
48
+ raise
49
+ rescue Leva::DspyConfigurationError => e
50
+ Rails.logger.error "[Leva::PromptOptimizationJob] Configuration error: #{e.message}"
51
+ @run&.fail!("Configuration error - please check server logs for details")
52
+ raise
53
+ rescue Leva::InsufficientDataError, Leva::OptimizationError => e
54
+ @run&.fail!(e)
55
+ Rails.logger.error "[Leva::PromptOptimizationJob] Optimization failed: #{e.message}"
56
+ raise
57
+ rescue StandardError => e
58
+ Rails.logger.error "[Leva::PromptOptimizationJob] Unexpected error: #{e.message}"
59
+ Rails.logger.error e.backtrace.first(10).join("\n")
60
+ @run&.fail!(e.message.truncate(500))
61
+ raise
62
+ end
63
+
64
+ private
65
+
66
+ # Callback for progress updates from the optimizer.
67
+ #
68
+ # @param step [String] Current step name
69
+ # @param progress [Integer] Progress percentage (0-100)
70
+ # @param examples_processed [Integer, nil] Number of examples processed
71
+ # @param total [Integer, nil] Total examples to process
72
+ # @return [void]
73
+ def update_progress(step:, progress:, examples_processed: nil, total: nil)
74
+ @run.update_progress(
75
+ step: step,
76
+ progress: progress,
77
+ examples_processed: examples_processed,
78
+ total: total
79
+ )
80
+ end
81
+ end
82
+ end
@@ -16,7 +16,6 @@ module Leva
16
16
  has_many :experiments
17
17
 
18
18
  validates :name, presence: true
19
- validates :system_prompt, presence: true
20
19
  validates :user_prompt, presence: true
21
20
 
22
21
  before_save :increment_version
@@ -21,6 +21,7 @@ module Leva
21
21
  end
22
22
 
23
23
  # Converts all dataset records to DSPy example format.
24
+ # Uses to_dspy_context if available, otherwise falls back to to_llm_context.
24
25
  #
25
26
  # @return [Array<Hash>] Array of example hashes with :input and :expected keys
26
27
  def to_dspy_examples
@@ -28,8 +29,8 @@ module Leva
28
29
  next unless record.recordable
29
30
 
30
31
  {
31
- input: record.recordable.to_llm_context,
32
- expected: { output: record.recordable.ground_truth }
32
+ input: sanitize_context(context_for(record.recordable)),
33
+ expected: { output: record.recordable.ground_truth.to_s }
33
34
  }
34
35
  end.compact
35
36
  end
@@ -60,5 +61,28 @@ module Leva
60
61
  def valid_record_count
61
62
  to_dspy_examples.size
62
63
  end
64
+
65
+ private
66
+
67
+ # Returns the context for a recordable, preferring to_dspy_context if available.
68
+ #
69
+ # @param recordable [Object] The recordable object
70
+ # @return [Hash] The context hash
71
+ def context_for(recordable)
72
+ if recordable.respond_to?(:to_dspy_context)
73
+ recordable.to_dspy_context
74
+ else
75
+ recordable.to_llm_context
76
+ end
77
+ end
78
+
79
+ # Sanitizes context hash by converting nil values to empty strings.
80
+ # DSPy signatures require String types, not nil.
81
+ #
82
+ # @param context [Hash] The LLM context hash
83
+ # @return [Hash] Sanitized hash with nil values converted to empty strings
84
+ def sanitize_context(context)
85
+ context.transform_values { |v| v.nil? ? "" : v.to_s }
86
+ end
63
87
  end
64
88
  end
@@ -181,6 +181,7 @@ module Leva
181
181
  end
182
182
 
183
183
  # Builds the final result hash from optimization.
184
+ # Follows DSPy-style format: instruction + examples + input in user prompt.
184
185
  #
185
186
  # @param result [Hash] The optimizer result with :instruction, :few_shot_examples, :score
186
187
  # @param splits [Hash] The data splits
@@ -188,15 +189,15 @@ module Leva
188
189
  # @return [Hash] The formatted result
189
190
  def build_final_result(result, splits, optimizer_type)
190
191
  sample_record = @dataset.dataset_records.first&.recordable
191
- input_fields = sample_record&.to_llm_context&.keys || []
192
+ input_fields = context_for(sample_record)&.keys || []
192
193
 
193
194
  formatted_examples = result[:few_shot_examples].map do |ex|
194
195
  { input: ex[:input], output: ex.dig(:expected, :output) }
195
196
  end
196
197
 
197
198
  {
198
- system_prompt: result[:instruction],
199
- user_prompt: build_user_prompt_template(input_fields),
199
+ system_prompt: "",
200
+ user_prompt: build_dspy_user_prompt(result[:instruction], formatted_examples, input_fields),
200
201
  metadata: {
201
202
  optimization: {
202
203
  score: result[:score],
@@ -275,6 +276,20 @@ module Leva
275
276
  MSG
276
277
  end
277
278
 
279
+ # Returns the context for a recordable, preferring to_dspy_context if available.
280
+ #
281
+ # @param recordable [Object] The recordable object
282
+ # @return [Hash] The context hash
283
+ def context_for(recordable)
284
+ return nil unless recordable
285
+
286
+ if recordable.respond_to?(:to_dspy_context)
287
+ recordable.to_dspy_context
288
+ else
289
+ recordable.to_llm_context
290
+ end
291
+ end
292
+
278
293
  # Returns the default evaluation metric (case-insensitive exact match).
279
294
  # Handles both Hash examples and DSPy::Example objects.
280
295
  #
@@ -294,12 +309,41 @@ module Leva
294
309
  end
295
310
  end
296
311
 
297
- # Builds the user prompt template with Liquid placeholders.
312
+ # Builds a DSPy-style user prompt with instruction, examples, and input placeholders.
298
313
  #
314
+ # @param instruction [String] The task instruction
315
+ # @param examples [Array<Hash>] The few-shot examples
299
316
  # @param input_fields [Array<Symbol>] The input field names
300
- # @return [String] The user prompt template
301
- def build_user_prompt_template(input_fields)
302
- input_fields.map { |field| "{{ #{field} }}" }.join("\n\n")
317
+ # @return [String] The DSPy-style user prompt template
318
+ def build_dspy_user_prompt(instruction, examples, input_fields)
319
+ sections = []
320
+
321
+ # Instruction
322
+ sections << instruction if instruction.present?
323
+
324
+ # Few-shot examples (DSPy style)
325
+ if examples.any?
326
+ sections << ""
327
+ sections << "---"
328
+ sections << ""
329
+ examples.each_with_index do |example, index|
330
+ sections << "Example #{index + 1}:"
331
+ example[:input].each do |field, value|
332
+ sections << "#{field}: #{value}"
333
+ end
334
+ sections << "Output: #{example[:output]}"
335
+ sections << ""
336
+ end
337
+ sections << "---"
338
+ end
339
+
340
+ # Input placeholders (Liquid)
341
+ sections << ""
342
+ input_fields.each do |field|
343
+ sections << "#{field}: {{ #{field} }}"
344
+ end
345
+
346
+ sections.join("\n")
303
347
  end
304
348
  end
305
349
  end
@@ -43,14 +43,27 @@ module Leva
43
43
 
44
44
  private
45
45
 
46
- # Extracts input fields from the sample record's LLM context.
46
+ # Extracts input fields from the sample record's context.
47
+ # Uses to_dspy_context if available, otherwise falls back to to_llm_context.
47
48
  #
48
49
  # @return [Hash<Symbol, Class>] Map of field names to their inferred types
49
50
  def extract_input_fields
50
- context = @sample_record.to_llm_context
51
+ context = context_for(@sample_record)
51
52
  context.transform_values { |value| infer_type(value) }
52
53
  end
53
54
 
55
+ # Returns the context for a recordable, preferring to_dspy_context if available.
56
+ #
57
+ # @param recordable [Object] The recordable object
58
+ # @return [Hash] The context hash
59
+ def context_for(recordable)
60
+ if recordable.respond_to?(:to_dspy_context)
61
+ recordable.to_dspy_context
62
+ else
63
+ recordable.to_llm_context
64
+ end
65
+ end
66
+
54
67
  # Infers the Ruby type for a given value.
55
68
  #
56
69
  # @param value [Object] The value to analyze
@@ -0,0 +1,257 @@
1
+ <% content_for :title, "Optimize Prompt - #{@dataset.name}" %>
2
+ <div class="container page">
3
+ <div class="page-header">
4
+ <div>
5
+ <div class="breadcrumb mb-2">
6
+ <%= link_to "Datasets", datasets_path, class: "breadcrumb-link" %>
7
+ <span class="breadcrumb-sep">/</span>
8
+ <%= link_to @dataset.name, dataset_path(@dataset), class: "breadcrumb-link" %>
9
+ <span class="breadcrumb-sep">/</span>
10
+ <span class="breadcrumb-current">Optimize Prompt</span>
11
+ </div>
12
+ <h1 class="page-title">Optimize Prompt</h1>
13
+ <p class="text-muted text-sm mt-2" style="max-width: 600px;">
14
+ Use DSPy.rb to automatically discover optimal prompt instructions and few-shot examples for your dataset.
15
+ </p>
16
+ </div>
17
+ </div>
18
+
19
+ <section class="mb-8">
20
+ <div class="card">
21
+ <div class="card-header">
22
+ <h3 class="card-title">Dataset Information</h3>
23
+ </div>
24
+ <div class="card-body">
25
+ <div class="grid grid-cols-2 gap-4">
26
+ <div>
27
+ <label class="text-muted text-xs uppercase tracking-wide">Dataset</label>
28
+ <p class="text-lg font-medium"><%= @dataset.name %></p>
29
+ </div>
30
+ <div>
31
+ <label class="text-muted text-xs uppercase tracking-wide">Records</label>
32
+ <p class="text-lg font-medium">
33
+ <%= @record_count %>
34
+ <% if @can_optimize %>
35
+ <span class="badge badge-success ml-2">Ready</span>
36
+ <% else %>
37
+ <span class="badge badge-warning ml-2">Need <%= @records_needed %> more</span>
38
+ <% end %>
39
+ </p>
40
+ </div>
41
+ </div>
42
+ <p class="text-xs text-muted mt-4">
43
+ Minimum <%= Leva::PromptOptimizer::MINIMUM_EXAMPLES %> records required for optimization.
44
+ </p>
45
+ </div>
46
+ </div>
47
+ </section>
48
+
49
+ <% if @can_optimize %>
50
+ <section>
51
+ <div class="card">
52
+ <div class="card-header">
53
+ <h3 class="card-title">Optimization Settings</h3>
54
+ </div>
55
+ <div class="card-body">
56
+ <%= form_with url: dataset_optimization_path(@dataset), method: :post, local: true do |f| %>
57
+ <div class="form-group mb-4">
58
+ <%= f.label :prompt_name, "Prompt Name", class: "form-label" %>
59
+ <%= f.text_field :prompt_name,
60
+ value: "Optimized: #{@dataset.name}",
61
+ class: "form-input",
62
+ placeholder: "Enter a name for the optimized prompt" %>
63
+ <p class="form-hint">The name for the new prompt that will be created.</p>
64
+ </div>
65
+
66
+ <div class="form-group mb-4">
67
+ <%= f.label :model, "Model", class: "form-label" %>
68
+ <%= f.select :model,
69
+ @models.map { |m| ["#{m.name} (#{m.provider})", m.id] },
70
+ { selected: Leva::PromptOptimizer::DEFAULT_MODEL },
71
+ class: "form-select" %>
72
+ <p class="form-hint">The AI model to use for optimization. Make sure you have the API key configured.</p>
73
+ </div>
74
+
75
+ <div class="form-group mb-6">
76
+ <%= f.label :optimizer, "Optimizer", class: "form-label" %>
77
+ <div class="radio-group">
78
+ <% @optimizers.each do |key, config| %>
79
+ <label class="radio-card <%= 'disabled' unless Leva::PromptOptimizer.optimizer_available?(key) %>">
80
+ <%= f.radio_button :optimizer, key,
81
+ checked: key == :bootstrap,
82
+ disabled: !Leva::PromptOptimizer.optimizer_available?(key),
83
+ class: "radio-input" %>
84
+ <div class="radio-content">
85
+ <span class="radio-title">
86
+ <%= config[:name] %>
87
+ <% unless Leva::PromptOptimizer.optimizer_available?(key) %>
88
+ <span class="badge badge-muted ml-2">Requires <%= config[:gem] %></span>
89
+ <% end %>
90
+ </span>
91
+ <span class="radio-description"><%= config[:description] %></span>
92
+ </div>
93
+ </label>
94
+ <% end %>
95
+ </div>
96
+ </div>
97
+
98
+ <div class="form-group mb-6">
99
+ <%= f.label :mode, "Optimization Mode", class: "form-label" %>
100
+ <div class="radio-group">
101
+ <% @modes.each do |mode, config| %>
102
+ <label class="radio-card">
103
+ <%= f.radio_button :mode, mode, checked: mode == :light, class: "radio-input" %>
104
+ <div class="radio-content">
105
+ <span class="radio-title"><%= mode.to_s.capitalize %></span>
106
+ <span class="radio-description"><%= config[:description] %></span>
107
+ </div>
108
+ </label>
109
+ <% end %>
110
+ </div>
111
+ </div>
112
+
113
+ <div class="alert alert-info mb-6">
114
+ <svg class="icon-sm" viewBox="0 0 20 20" fill="currentColor">
115
+ <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
116
+ </svg>
117
+ <div>
118
+ <p class="font-medium">How it works</p>
119
+ <p class="text-sm mt-1">
120
+ The optimizer will analyze your dataset records to find the best prompt instructions
121
+ and select optimal few-shot examples. The process runs in the background.
122
+ </p>
123
+ </div>
124
+ </div>
125
+
126
+ <div class="flex gap-3">
127
+ <%= f.submit "Start Optimization", class: "btn btn-primary" %>
128
+ <%= link_to "Cancel", dataset_path(@dataset), class: "btn btn-ghost" %>
129
+ </div>
130
+ <% end %>
131
+ </div>
132
+ </div>
133
+ </section>
134
+ <% else %>
135
+ <section>
136
+ <div class="card">
137
+ <div class="card-body">
138
+ <div class="empty-state">
139
+ <svg class="empty-state-icon" viewBox="0 0 20 20" fill="currentColor">
140
+ <path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
141
+ </svg>
142
+ <h3 class="empty-state-title">Not Enough Data</h3>
143
+ <p class="empty-state-description">
144
+ Add at least <%= @records_needed %> more records to enable prompt optimization.
145
+ The optimizer requires a minimum of <%= Leva::PromptOptimizer::MINIMUM_EXAMPLES %> records
146
+ to find meaningful patterns.
147
+ </p>
148
+ <div class="mt-4">
149
+ <%= link_to "Back to Dataset", dataset_path(@dataset), class: "btn btn-primary" %>
150
+ </div>
151
+ </div>
152
+ </div>
153
+ </div>
154
+ </section>
155
+ <% end %>
156
+ </div>
157
+
158
+ <style>
159
+ .radio-group {
160
+ display: flex;
161
+ flex-direction: column;
162
+ gap: 0.75rem;
163
+ }
164
+
165
+ .radio-card {
166
+ display: flex;
167
+ align-items: flex-start;
168
+ gap: 0.75rem;
169
+ padding: 1rem;
170
+ border: 1px solid var(--border-color);
171
+ border-radius: 0.5rem;
172
+ cursor: pointer;
173
+ transition: border-color 0.15s, background-color 0.15s;
174
+ }
175
+
176
+ .radio-card:hover {
177
+ border-color: var(--primary);
178
+ background-color: var(--bg-secondary);
179
+ }
180
+
181
+ .radio-card:has(.radio-input:checked) {
182
+ border-color: var(--primary);
183
+ background-color: var(--bg-secondary);
184
+ }
185
+
186
+ .radio-input {
187
+ margin-top: 0.25rem;
188
+ }
189
+
190
+ .radio-content {
191
+ display: flex;
192
+ flex-direction: column;
193
+ gap: 0.25rem;
194
+ }
195
+
196
+ .radio-title {
197
+ font-weight: 500;
198
+ }
199
+
200
+ .radio-description {
201
+ font-size: 0.875rem;
202
+ color: var(--text-muted);
203
+ }
204
+
205
+ .badge {
206
+ display: inline-flex;
207
+ align-items: center;
208
+ padding: 0.125rem 0.5rem;
209
+ font-size: 0.75rem;
210
+ font-weight: 500;
211
+ border-radius: 9999px;
212
+ }
213
+
214
+ .badge-success {
215
+ background-color: rgba(34, 197, 94, 0.15);
216
+ color: rgb(34, 197, 94);
217
+ }
218
+
219
+ .badge-warning {
220
+ background-color: rgba(234, 179, 8, 0.15);
221
+ color: rgb(234, 179, 8);
222
+ }
223
+
224
+ .badge-muted {
225
+ background-color: var(--bg-secondary);
226
+ color: var(--text-muted);
227
+ font-size: 0.65rem;
228
+ }
229
+
230
+ .radio-card.disabled {
231
+ opacity: 0.5;
232
+ cursor: not-allowed;
233
+ }
234
+
235
+ .radio-card.disabled:hover {
236
+ border-color: var(--border-color);
237
+ background-color: transparent;
238
+ }
239
+
240
+ .alert {
241
+ display: flex;
242
+ gap: 0.75rem;
243
+ padding: 1rem;
244
+ border-radius: 0.5rem;
245
+ }
246
+
247
+ .alert-info {
248
+ background-color: rgba(59, 130, 246, 0.1);
249
+ border: 1px solid rgba(59, 130, 246, 0.2);
250
+ color: var(--text-primary);
251
+ }
252
+
253
+ .alert-info svg {
254
+ color: rgb(59, 130, 246);
255
+ flex-shrink: 0;
256
+ }
257
+ </style>
@@ -84,8 +84,69 @@
84
84
  <% end %>
85
85
  </section>
86
86
 
87
- <%# Optimized Prompts Section - TODO: Enable when DSPy routes are added %>
88
- <%# This feature is available in the PromptOptimizer service but UI routes are pending %>
87
+ <%# Prompt Optimization Section %>
88
+ <section class="mb-8">
89
+ <div class="section-header">
90
+ <h3 class="section-title">Prompt Optimization</h3>
91
+ <span class="section-count"><%= @dataset.optimization_runs.count %></span>
92
+ <div class="ml-auto">
93
+ <% optimizer = Leva::PromptOptimizer.new(dataset: @dataset) %>
94
+ <% if optimizer.can_optimize? %>
95
+ <%= link_to new_dataset_optimization_path(@dataset), class: "btn btn-primary btn-sm" do %>
96
+ <svg class="icon-sm" viewBox="0 0 20 20" fill="currentColor">
97
+ <path fill-rule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clip-rule="evenodd" />
98
+ </svg>
99
+ Optimize Prompt
100
+ <% end %>
101
+ <% else %>
102
+ <button class="btn btn-ghost btn-sm" disabled title="Need <%= optimizer.records_needed %> more records">
103
+ <svg class="icon-sm" viewBox="0 0 20 20" fill="currentColor">
104
+ <path fill-rule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clip-rule="evenodd" />
105
+ </svg>
106
+ Need <%= optimizer.records_needed %> more records
107
+ </button>
108
+ <% end %>
109
+ </div>
110
+ </div>
111
+
112
+ <% if @dataset.optimization_runs.any? %>
113
+ <div class="table-wrapper">
114
+ <div class="table-scroll">
115
+ <table class="table table-clickable">
116
+ <thead>
117
+ <tr>
118
+ <th>Prompt Name</th>
119
+ <th>Optimizer</th>
120
+ <th>Mode</th>
121
+ <th>Status</th>
122
+ <th class="text-right">Created</th>
123
+ </tr>
124
+ </thead>
125
+ <tbody>
126
+ <% @dataset.optimization_runs.order(created_at: :desc).each do |run| %>
127
+ <tr class="clickable-row" onclick="window.location='<%= optimization_run_path(run) %>'">
128
+ <td><span class="row-title"><%= run.prompt_name %></span></td>
129
+ <td><%= run.optimizer&.titleize || 'Bootstrap' %></td>
130
+ <td><%= run.mode&.titleize || 'Light' %></td>
131
+ <td>
132
+ <span class="badge badge-<%= run.status == 'completed' ? 'success' : (run.status == 'failed' ? 'error' : 'warning') %>">
133
+ <%= run.status&.titleize || 'Pending' %>
134
+ </span>
135
+ </td>
136
+ <td class="text-right text-muted"><%= time_ago_in_words(run.created_at) %> ago</td>
137
+ </tr>
138
+ <% end %>
139
+ </tbody>
140
+ </table>
141
+ </div>
142
+ </div>
143
+ <% else %>
144
+ <div class="empty-state-inline">
145
+ <p class="text-muted text-sm">No optimization runs yet.</p>
146
+ <p class="text-xs text-subtle mt-2">Use DSPy to optimize your prompts with few-shot examples.</p>
147
+ </div>
148
+ <% end %>
149
+ </section>
89
150
 
90
151
  <%# Experiments Section %>
91
152
  <section>
@@ -126,6 +187,7 @@
126
187
  </tr>
127
188
  </thead>
128
189
  <tbody>
190
+ <% @evaluator_classes = Leva::EvaluationResult.distinct.pluck(:evaluator_class) %>
129
191
  <%= render partial: 'leva/experiments/experiment', collection: @dataset.experiments %>
130
192
  </tbody>
131
193
  </table>
data/config/routes.rb CHANGED
@@ -3,8 +3,11 @@ Leva::Engine.routes.draw do
3
3
 
4
4
  get "design_system", to: "design_system#index"
5
5
 
6
+ resources :optimization_runs, only: [ :show ]
7
+
6
8
  resources :datasets do
7
9
  resources :dataset_records, path: "records", only: [ :index, :show ]
10
+ resource :optimization, only: [ :new, :create ], controller: "dataset_optimizations"
8
11
  end
9
12
  resources :experiments, except: [ :destroy ] do
10
13
  member do
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Leva
4
+ # Base class for runners that use DSPy.rb for LLM execution.
5
+ #
6
+ # DspyRunner extends BaseRun to provide integration with DSPy.rb,
7
+ # automatically loading optimized instructions and few-shot examples
8
+ # from the prompt's metadata.
9
+ #
10
+ # @example Create a custom DSPy runner
11
+ # class SentimentRunner < Leva::DspyRunner
12
+ # # DspyRunner handles the execution automatically
13
+ # # using the optimized prompt from the experiment
14
+ # end
15
+ #
16
+ # @example Override for custom behavior
17
+ # class CustomRunner < Leva::DspyRunner
18
+ # def execute(record)
19
+ # context = merged_llm_context
20
+ # # Custom execution logic here
21
+ # end
22
+ # end
23
+ class DspyRunner < BaseRun
24
+ # Executes the DSPy predictor on the given record.
25
+ #
26
+ # @param record [Object] The recordable object to process
27
+ # @return [String] The model's prediction
28
+ def execute(record)
29
+ context = merged_llm_context
30
+
31
+ if optimized_prompt?
32
+ execute_with_optimization(context)
33
+ else
34
+ execute_simple(context)
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ # Checks if the prompt has optimization metadata.
41
+ #
42
+ # @return [Boolean] True if the prompt has optimization data
43
+ def optimized_prompt?
44
+ @prompt&.metadata&.dig("optimization", "few_shot_examples").present?
45
+ end
46
+
47
+ # Executes with optimized instruction and few-shot examples.
48
+ #
49
+ # @param context [Hash] The merged LLM context
50
+ # @return [String] The prediction
51
+ def execute_with_optimization(context)
52
+ # In a full implementation, this would:
53
+ # 1. Load the DSPy signature
54
+ # 2. Create a predictor with the optimized instruction
55
+ # 3. Add few-shot examples
56
+ # 4. Execute and return the result
57
+
58
+ # For now, we render the prompt template and return a placeholder
59
+ # that indicates this needs actual DSPy integration
60
+ instruction = @prompt.system_prompt
61
+ user_prompt = render_user_prompt(context)
62
+ few_shot_examples = @prompt.metadata.dig("optimization", "few_shot_examples") || []
63
+
64
+ # Build a formatted prompt string for demonstration
65
+ build_prompt_string(instruction, few_shot_examples, user_prompt)
66
+ end
67
+
68
+ # Executes a simple prediction without optimization.
69
+ #
70
+ # @param context [Hash] The merged LLM context
71
+ # @return [String] The prediction
72
+ def execute_simple(context)
73
+ instruction = @prompt&.system_prompt || ""
74
+ user_prompt = render_user_prompt(context)
75
+
76
+ "#{instruction}\n\n#{user_prompt}"
77
+ end
78
+
79
+ # Renders the user prompt template with the given context.
80
+ #
81
+ # @param context [Hash] The context for template rendering
82
+ # @return [String] The rendered prompt
83
+ def render_user_prompt(context)
84
+ return "" unless @prompt&.user_prompt
85
+
86
+ template = Liquid::Template.parse(@prompt.user_prompt)
87
+ template.render(context.stringify_keys)
88
+ end
89
+
90
+ # Builds a formatted prompt string including few-shot examples.
91
+ #
92
+ # @param instruction [String] The system instruction
93
+ # @param examples [Array<Hash>] The few-shot examples
94
+ # @param user_prompt [String] The user's input prompt
95
+ # @return [String] The formatted prompt
96
+ def build_prompt_string(instruction, examples, user_prompt)
97
+ parts = []
98
+ parts << instruction if instruction.present?
99
+
100
+ if examples.any?
101
+ parts << "\n--- Examples ---"
102
+ examples.each_with_index do |example, index|
103
+ parts << "\nExample #{index + 1}:"
104
+ parts << "Input: #{example['input'].to_json}"
105
+ parts << "Output: #{example['output']}"
106
+ end
107
+ parts << "\n--- Your Turn ---"
108
+ end
109
+
110
+ parts << user_prompt if user_prompt.present?
111
+
112
+ parts.join("\n")
113
+ end
114
+
115
+ # Generates a signature class for the current dataset.
116
+ #
117
+ # @return [Class] The generated signature class
118
+ def build_signature
119
+ SignatureGenerator.new(@experiment.dataset).generate
120
+ end
121
+ end
122
+ end
data/lib/leva/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Leva
2
- VERSION = "0.3.1"
2
+ VERSION = "0.3.3"
3
3
  end
data/lib/leva.rb CHANGED
@@ -158,3 +158,6 @@ module Leva
158
158
  end
159
159
  end
160
160
  end
161
+
162
+ # Load DspyRunner after BaseRun is defined
163
+ require "leva/dspy_runner"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: leva
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.3.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kieran Klaassen
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-12-07 00:00:00.000000000 Z
11
+ date: 2025-12-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -58,6 +58,7 @@ files:
58
58
  - app/controllers/leva/datasets_controller.rb
59
59
  - app/controllers/leva/design_system_controller.rb
60
60
  - app/controllers/leva/experiments_controller.rb
61
+ - app/controllers/leva/optimization_runs_controller.rb
61
62
  - app/controllers/leva/runner_results_controller.rb
62
63
  - app/controllers/leva/workbench_controller.rb
63
64
  - app/helpers/leva/application_helper.rb
@@ -65,6 +66,7 @@ files:
65
66
  - app/javascript/controllers/prompt_selector_controller.js
66
67
  - app/jobs/leva/application_job.rb
67
68
  - app/jobs/leva/experiment_job.rb
69
+ - app/jobs/leva/prompt_optimization_job.rb
68
70
  - app/jobs/leva/run_eval_job.rb
69
71
  - app/mailers/leva/application_mailer.rb
70
72
  - app/models/concerns/leva/recordable.rb
@@ -85,6 +87,7 @@ files:
85
87
  - app/services/leva/prompt_optimizer.rb
86
88
  - app/services/leva/signature_generator.rb
87
89
  - app/views/layouts/leva/application.html.erb
90
+ - app/views/leva/dataset_optimizations/new.html.erb
88
91
  - app/views/leva/dataset_records/index.html.erb
89
92
  - app/views/leva/dataset_records/show.html.erb
90
93
  - app/views/leva/datasets/_dataset.html.erb
@@ -124,6 +127,7 @@ files:
124
127
  - lib/generators/leva/templates/eval.rb.erb
125
128
  - lib/generators/leva/templates/runner.rb.erb
126
129
  - lib/leva.rb
130
+ - lib/leva/dspy_runner.rb
127
131
  - lib/leva/engine.rb
128
132
  - lib/leva/errors.rb
129
133
  - lib/leva/version.rb