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 +4 -4
- data/README.md +12 -1
- data/app/controllers/leva/optimization_runs_controller.rb +24 -0
- data/app/helpers/leva/application_helper.rb +21 -0
- data/app/jobs/leva/prompt_optimization_job.rb +82 -0
- data/app/models/leva/prompt.rb +0 -1
- data/app/services/leva/dataset_converter.rb +26 -2
- data/app/services/leva/prompt_optimizer.rb +51 -7
- data/app/services/leva/signature_generator.rb +15 -2
- data/app/views/leva/dataset_optimizations/new.html.erb +257 -0
- data/app/views/leva/datasets/show.html.erb +64 -2
- data/config/routes.rb +3 -0
- data/lib/leva/dspy_runner.rb +122 -0
- data/lib/leva/version.rb +1 -1
- data/lib/leva.rb +3 -0
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: df2ad9b893851f23ca75b7f498279ff86b8687fa38d783bc5d33001327cd4928
|
|
4
|
+
data.tar.gz: 920b07a223df9e0f89f6e7f56d81270f12749b2afc9d47817d70069e17bab582
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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: "
|
|
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
|
data/app/models/leva/prompt.rb
CHANGED
|
@@ -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
|
|
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&.
|
|
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:
|
|
199
|
-
user_prompt:
|
|
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
|
|
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
|
|
302
|
-
|
|
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
|
|
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
|
|
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
|
-
<%#
|
|
88
|
-
|
|
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
data/lib/leva.rb
CHANGED
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.
|
|
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-
|
|
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
|