leva 0.3.2 → 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 +8 -0
- 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 +222 -110
- data/config/routes.rb +2 -1
- data/lib/leva/dspy_runner.rb +122 -0
- data/lib/leva/version.rb +1 -1
- data/lib/leva.rb +3 -0
- metadata +4 -1
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")
|
|
@@ -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
|
|
@@ -1,145 +1,257 @@
|
|
|
1
|
-
<% content_for :title,
|
|
1
|
+
<% content_for :title, "Optimize Prompt - #{@dataset.name}" %>
|
|
2
2
|
<div class="container page">
|
|
3
|
-
<
|
|
4
|
-
<%= link_to "Datasets", datasets_path, class: "breadcrumb-link" %>
|
|
5
|
-
<span class="breadcrumb-sep">/</span>
|
|
6
|
-
<%= link_to @dataset.name, dataset_path(@dataset), class: "breadcrumb-link" %>
|
|
7
|
-
<span class="breadcrumb-sep">/</span>
|
|
8
|
-
<span class="breadcrumb-current">Optimize</span>
|
|
9
|
-
</nav>
|
|
10
|
-
|
|
11
|
-
<div class="page-header mb-6">
|
|
3
|
+
<div class="page-header">
|
|
12
4
|
<div>
|
|
13
|
-
<
|
|
14
|
-
|
|
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>
|
|
15
16
|
</div>
|
|
16
17
|
</div>
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
<div class="form-group">
|
|
23
|
-
<%= form.label :prompt_name, "Prompt Name", class: "form-label" %>
|
|
24
|
-
<%= form.text_field :prompt_name, value: "Optimized: #{@dataset.name}", autofocus: true, class: "form-input", placeholder: "e.g., Optimized Sentiment Classifier" %>
|
|
25
|
-
<p class="form-hint">Name for the new optimized prompt that will be created.</p>
|
|
26
|
-
</div>
|
|
19
|
+
<section class="mb-8">
|
|
20
|
+
<div class="card">
|
|
21
|
+
<div class="card-header">
|
|
22
|
+
<h3 class="card-title">Dataset Information</h3>
|
|
27
23
|
</div>
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
<h4 class="form-section-title">Optimization Settings</h4>
|
|
34
|
-
|
|
35
|
-
<div class="form-row">
|
|
36
|
-
<div class="form-group flex-1">
|
|
37
|
-
<%= form.label :optimizer, "Optimizer", class: "form-label" %>
|
|
38
|
-
<%= form.select :optimizer,
|
|
39
|
-
@optimizers.map { |k, v| [v[:name], k] },
|
|
40
|
-
{},
|
|
41
|
-
class: "form-select" %>
|
|
42
|
-
<p class="form-hint">Algorithm used to optimize the prompt.</p>
|
|
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>
|
|
43
29
|
</div>
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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>
|
|
52
40
|
</div>
|
|
53
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>
|
|
54
48
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
class: "form-select" %>
|
|
61
|
-
<p class="form-hint">The AI model to use during optimization.</p>
|
|
49
|
+
<% if @can_optimize %>
|
|
50
|
+
<section>
|
|
51
|
+
<div class="card">
|
|
52
|
+
<div class="card-header">
|
|
53
|
+
<h3 class="card-title">Optimization Settings</h3>
|
|
62
54
|
</div>
|
|
63
|
-
|
|
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>
|
|
64
65
|
|
|
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>
|
|
66
74
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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>
|
|
81
112
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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>
|
|
85
132
|
</div>
|
|
86
|
-
|
|
133
|
+
</section>
|
|
87
134
|
<% else %>
|
|
88
|
-
<
|
|
89
|
-
<div class="
|
|
90
|
-
<div class="
|
|
91
|
-
<
|
|
92
|
-
<
|
|
93
|
-
|
|
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>
|
|
94
152
|
</div>
|
|
95
|
-
<h3 class="setup-required-title">More Records Needed</h3>
|
|
96
|
-
<p class="setup-required-desc">You need at least <strong>10 records</strong> to optimize a prompt. Currently you have <strong><%= @record_count %></strong>.</p>
|
|
97
|
-
<p class="setup-required-hint mt-2">Add <strong><%= @records_needed %></strong> more records to enable optimization.</p>
|
|
98
|
-
</div>
|
|
99
|
-
|
|
100
|
-
<div class="form-actions">
|
|
101
|
-
<%= link_to dataset_path(@dataset), class: "btn btn-ghost" do %>
|
|
102
|
-
<svg class="icon-sm" viewBox="0 0 20 20" fill="currentColor">
|
|
103
|
-
<path fill-rule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clip-rule="evenodd" />
|
|
104
|
-
</svg>
|
|
105
|
-
Back to Dataset
|
|
106
|
-
<% end %>
|
|
107
153
|
</div>
|
|
108
|
-
</
|
|
154
|
+
</section>
|
|
109
155
|
<% end %>
|
|
110
156
|
</div>
|
|
111
157
|
|
|
112
158
|
<style>
|
|
113
|
-
.
|
|
114
|
-
display:
|
|
115
|
-
|
|
116
|
-
gap:
|
|
159
|
+
.radio-group {
|
|
160
|
+
display: flex;
|
|
161
|
+
flex-direction: column;
|
|
162
|
+
gap: 0.75rem;
|
|
117
163
|
}
|
|
118
164
|
|
|
119
|
-
.
|
|
120
|
-
|
|
121
|
-
|
|
165
|
+
.radio-card {
|
|
166
|
+
display: flex;
|
|
167
|
+
align-items: flex-start;
|
|
168
|
+
gap: 0.75rem;
|
|
122
169
|
padding: 1rem;
|
|
123
|
-
|
|
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;
|
|
124
174
|
}
|
|
125
175
|
|
|
126
|
-
.
|
|
127
|
-
|
|
128
|
-
|
|
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;
|
|
129
202
|
color: var(--text-muted);
|
|
130
|
-
text-transform: uppercase;
|
|
131
|
-
letter-spacing: 0.04em;
|
|
132
|
-
margin-bottom: 0.25rem;
|
|
133
203
|
}
|
|
134
204
|
|
|
135
|
-
.
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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);
|
|
139
222
|
}
|
|
140
223
|
|
|
141
|
-
.
|
|
224
|
+
.badge-muted {
|
|
225
|
+
background-color: var(--bg-secondary);
|
|
142
226
|
color: var(--text-muted);
|
|
143
|
-
font-size: 0.
|
|
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;
|
|
144
256
|
}
|
|
145
257
|
</style>
|
data/config/routes.rb
CHANGED
|
@@ -3,11 +3,12 @@ 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 ]
|
|
8
10
|
resource :optimization, only: [ :new, :create ], controller: "dataset_optimizations"
|
|
9
11
|
end
|
|
10
|
-
resources :optimization_runs, only: [ :show ]
|
|
11
12
|
resources :experiments, except: [ :destroy ] do
|
|
12
13
|
member do
|
|
13
14
|
post :rerun
|
|
@@ -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,7 +1,7 @@
|
|
|
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
|
|
@@ -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
|
|
@@ -125,6 +127,7 @@ files:
|
|
|
125
127
|
- lib/generators/leva/templates/eval.rb.erb
|
|
126
128
|
- lib/generators/leva/templates/runner.rb.erb
|
|
127
129
|
- lib/leva.rb
|
|
130
|
+
- lib/leva/dspy_runner.rb
|
|
128
131
|
- lib/leva/engine.rb
|
|
129
132
|
- lib/leva/errors.rb
|
|
130
133
|
- lib/leva/version.rb
|