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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e25afa90f1e4ad86dbc306bc26aea4f44c157bd18b1a666740be9ea8709e395c
4
- data.tar.gz: abbcde1d2853b07a825aa5558f8f50e2c6ae92eeda3290e86af78cbf328ab2bc
3
+ metadata.gz: df2ad9b893851f23ca75b7f498279ff86b8687fa38d783bc5d33001327cd4928
4
+ data.tar.gz: 920b07a223df9e0f89f6e7f56d81270f12749b2afc9d47817d70069e17bab582
5
5
  SHA512:
6
- metadata.gz: c0a250ede2a3eb3868f9b3c26bc75e61d20b05dd5e28d8f71ebff40f39a027e01040b8f58a8c372fd0bbf3226e62defa158f8e08c092b6b21653638f4843518a
7
- data.tar.gz: 48a2bc4b8a76606d85aabdaeb3fa59d50006d1c6ba4eedd68373839d2d24115929b82a6e657a66f4527ce8559949eb9790c39ec362d5cfa4d86edb56651f6e7c
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
@@ -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
@@ -1,145 +1,257 @@
1
- <% content_for :title, 'Optimize Prompt' %>
1
+ <% content_for :title, "Optimize Prompt - #{@dataset.name}" %>
2
2
  <div class="container page">
3
- <nav class="breadcrumb mb-4">
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
- <h1>Optimize Prompt</h1>
14
- <p class="text-muted text-sm mt-1">Use DSPy to automatically optimize your prompt with few-shot examples</p>
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
- <% if @can_optimize %>
19
- <%= form_with url: dataset_optimization_path(@dataset), method: :post, local: true, class: "card p-6" do |form| %>
20
- <%# Basic Information %>
21
- <div class="form-section">
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
- <hr class="form-divider">
30
-
31
- <%# Optimization Settings %>
32
- <div class="form-section">
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
- <div class="form-group flex-1">
46
- <%= form.label :mode, "Mode", class: "form-label" %>
47
- <%= form.select :mode,
48
- @modes.map { |k, v| ["#{v[:name]} (#{v[:description]})", k] },
49
- {},
50
- class: "form-select" %>
51
- <p class="form-hint">Higher modes use more examples but take longer.</p>
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
- <div class="form-group">
56
- <%= form.label :model, "Model", class: "form-label" %>
57
- <%= form.select :model,
58
- @models.map { |m| [m.name, m.id] },
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
- </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>
64
65
 
65
- <hr class="form-divider">
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
- <%# Dataset Info %>
68
- <div class="form-section">
69
- <h4 class="form-section-title">Dataset Information</h4>
70
- <div class="info-grid">
71
- <div class="info-item">
72
- <span class="info-label">Records Available</span>
73
- <span class="info-value"><%= @record_count %></span>
74
- </div>
75
- <div class="info-item">
76
- <span class="info-label">Minimum Required</span>
77
- <span class="info-value">10</span>
78
- </div>
79
- </div>
80
- </div>
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
- <div class="form-actions">
83
- <%= link_to "Cancel", dataset_path(@dataset), class: "btn btn-ghost" %>
84
- <%= form.submit "Start Optimization", class: "btn btn-primary" %>
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
- <% end %>
133
+ </section>
87
134
  <% else %>
88
- <div class="card p-6">
89
- <div class="setup-required">
90
- <div class="setup-required-icon">
91
- <svg class="icon-xl" fill="none" viewBox="0 0 24 24" stroke="currentColor">
92
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
93
- </svg>
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
- </div>
154
+ </section>
109
155
  <% end %>
110
156
  </div>
111
157
 
112
158
  <style>
113
- .info-grid {
114
- display: grid;
115
- grid-template-columns: repeat(2, 1fr);
116
- gap: 1rem;
159
+ .radio-group {
160
+ display: flex;
161
+ flex-direction: column;
162
+ gap: 0.75rem;
117
163
  }
118
164
 
119
- .info-item {
120
- background: var(--bg-secondary);
121
- border-radius: 0.5rem;
165
+ .radio-card {
166
+ display: flex;
167
+ align-items: flex-start;
168
+ gap: 0.75rem;
122
169
  padding: 1rem;
123
- text-align: center;
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
- .info-label {
127
- display: block;
128
- font-size: 0.75rem;
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
- .info-value {
136
- font-size: 1.5rem;
137
- font-weight: 600;
138
- font-family: 'Fira Code', monospace;
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
- .setup-required-hint {
224
+ .badge-muted {
225
+ background-color: var(--bg-secondary);
142
226
  color: var(--text-muted);
143
- font-size: 0.875rem;
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
@@ -1,3 +1,3 @@
1
1
  module Leva
2
- VERSION = "0.3.2"
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,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: leva
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
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