ruby_llm-llm_judge 0.1.2 → 0.1.4
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/CHANGELOG.md +14 -0
- data/README.md +29 -2
- data/lib/ruby_llm/llm_judge/engine.rb +125 -30
- data/lib/ruby_llm/llm_judge/provider.rb +4 -1
- data/lib/ruby_llm/llm_judge/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fc81f9575b6468ad1a7ea81b19d777ea169eca0619cf1995c59c0aab01128254
|
|
4
|
+
data.tar.gz: 6052e01d0b02b576c25fe5baa0545a8f89e9060e12d42ae65f3fa99e8e14f5f7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ba27da7ce65adbb3d39d6447decaa6b2454d2bbee7086577f81798cb213ccf573efd81c250d46a5bc29d7b58d811892a73918b7f89cddd1dec778b50620d8406
|
|
7
|
+
data.tar.gz: 411fd92bb9f7700b6075559ed5644cbdad5485a555fc6601249ebfc1c28d657dbc221255bf308f4358c1730cd3c506b08c553a7e2359ddb0e449a2c0e5aedaf7
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.4 (2026-10-03)
|
|
4
|
+
|
|
5
|
+
- `structured_output: true` sends a strict JSON Schema with one-call requests, requiring every question and option.
|
|
6
|
+
- `strategy: :committed` asks for one option per question and returns it with probability 1.
|
|
7
|
+
- The provider accepts RubyLLM main's `with:` attachments argument and raises `UnsupportedAttachmentError` when images are passed. Judgments on RubyLLM main failed with `ArgumentError` before.
|
|
8
|
+
- The README explains when to enable reasoning: off suits classification, while rule-following questions need it. See [the decision-mode benchmarks](benchmarks/decision-modes.md).
|
|
9
|
+
|
|
10
|
+
## 0.1.3 (2026-09-29)
|
|
11
|
+
|
|
12
|
+
- Setting a `chat_provider_options` key to `nil` removes that field, including the Luna defaults and, before RubyLLM 2.1, the output limit field. A nested hash emptied this way is removed too.
|
|
13
|
+
- One-call answers given as a list of single-question objects are accepted when each question appears once. Claude Haiku 4.5 returns this shape on its first attempt, which previously cost a corrective retry on nearly every judgment.
|
|
14
|
+
- The one-call instructions now say that `answers` is an object keyed by question ID. Haiku no longer needs corrective retries (59 of 96 benchmark cases before), and Luna scored at least as well in a back-to-back comparison. See [the Haiku benchmark](benchmarks/haiku.md).
|
|
15
|
+
- Luna defaults apply to every OpenAI Luna model ID (`gpt-6-luna`, `gpt-5.6-luna`, `gpt-luna-latest`) and to OpenRouter's `openai/` Luna IDs, not only `gpt-6-luna` on OpenAI. On OpenRouter they send temperature zero and `reasoning: { enabled: false }`. With reasoning on, Luna's ratings often returned no digit. Pro variants and prefixed gateway IDs, such as `us.openai.gpt-5.6-luna` through `:openai`, are excluded.
|
|
16
|
+
|
|
3
17
|
## 0.1.2 (2026-09-28)
|
|
4
18
|
|
|
5
19
|
- When a rating call fails, no new calls start and the error is raised once calls in flight finish. Previously the remaining calls kept running, and were billed, after `judge` raised.
|
data/README.md
CHANGED
|
@@ -84,9 +84,11 @@ result = RubyLLM::LLMJudge.judge(
|
|
|
84
84
|
)
|
|
85
85
|
```
|
|
86
86
|
|
|
87
|
-
Both strategies return valid judgments with this model. Its one-call answers arrive in a Markdown code fence, which the gem removes before parsing.
|
|
87
|
+
Both strategies return valid judgments with this model. Its one-call answers arrive in a Markdown code fence, which the gem removes before parsing. On the benchmark cases it scored 56–57/64 on AG News and 29–30/32 on SST-2 at about 1.1 s median, a little below Luna. [Haiku results](benchmarks/haiku.md).
|
|
88
88
|
|
|
89
|
-
The default Luna rating request uses Chat Completions, temperature zero, reasoning disabled, `store: false`, and a four-token output limit. Tune requests with `scoring_protocol`, `temperature`, `max_output_tokens`, and `chat_provider_options`. `chat_provider_options` is merged over those defaults, so setting one key, such as `service_tier: 'flex'`, keeps reasoning disabled; set a key explicitly to change a default.
|
|
89
|
+
The default Luna rating request uses Chat Completions, temperature zero, reasoning disabled, `store: false`, and a four-token output limit. Tune requests with `scoring_protocol`, `temperature`, `max_output_tokens`, and `chat_provider_options`. `chat_provider_options` is merged over those defaults, so setting one key, such as `service_tier: 'flex'`, keeps reasoning disabled; set a key explicitly to change a default. Set a key to `nil` to remove that field from the request, whether it is a default or, before RubyLLM 2.1, the output limit field. For example, an app that routes RubyLLM 1.x OpenAI chats through the Responses API can send `chat_provider_options: { max_completion_tokens: nil, max_output_tokens: 256 }`.
|
|
90
|
+
|
|
91
|
+
Luna defaults apply to each provider's own Luna model IDs: `gpt-6-luna`, `gpt-5.6-luna`, or `gpt-luna-latest` on `:openai`, which sends temperature zero, `reasoning_effort: 'none'`, and `store: false`; and `openai/gpt-6-luna` or `openai/gpt-5.6-luna` on `:openrouter`, which sends temperature zero and `reasoning: { enabled: false }`. Pro variants, other providers such as `:bedrock`, and prefixed IDs such as `us.openai.gpt-5.6-luna` sent through `:openai` to an OpenAI-compatible gateway get no Luna defaults, because their request format may differ; set `temperature:` and disable reasoning in that endpoint's format yourself. With reasoning left on, the ratings strategy's four-token output limit is spent on reasoning: on eight AG News cases, `gpt-6-luna` through OpenRouter returned no digit for all eight, and `gpt-5.6-luna` for four. RubyLLM before 2.1 sends temperature 1.0 for any OpenAI model whose ID starts with `gpt-5`, so `gpt-5.6-luna` on `:openai` runs at 1.0 there unless you pass `chat_provider_options: { temperature: 0 }`.
|
|
90
92
|
|
|
91
93
|
## One-call typed decisions
|
|
92
94
|
|
|
@@ -140,6 +142,31 @@ result = RubyLLM::LLMJudge.judge(
|
|
|
140
142
|
|
|
141
143
|
OpenRouter's [latency sorting](https://openrouter.ai/docs/guides/routing/provider-selection) chooses among its available providers using their observed response times. It sped up OpenRouter in our test, while direct OpenAI was faster still. The measured speed and answer-quality tradeoffs are below.
|
|
142
144
|
|
|
145
|
+
### Strict response schemas
|
|
146
|
+
|
|
147
|
+
Set `structured_output: true` to send a strict JSON Schema with the one-call request, through RubyLLM's `with_schema`. The schema requires every question and every option, and nothing else, so a provider that supports structured outputs cannot return the wrong shape. Without it, the gem validates the reply and makes its corrective retry. In our runs it had little effect on accuracy, cut GPT-6 Luna's corrective retries from 3.3 to 0.7 per 96 judgments, and added 40–350 ms. Models and providers without structured-output support reject the request, so leave it off for them.
|
|
148
|
+
|
|
149
|
+
### Committed answers
|
|
150
|
+
|
|
151
|
+
Set `strategy: :committed` to ask for one option per question instead of a distribution. The reply is shorter, and each answer comes back with probability 1 for the chosen option and 0 for the rest, so `confidence` is always 1.0 and the probabilities carry no calibration. Use it when you only need the decision; use `:single_request` or `:ratings` when you threshold on probabilities. It accepts `structured_output: true`, which limits each answer to that question's option IDs. On classification it was 100–300 ms faster than probabilities and as accurate or up to three cases in 64 less; on rule-following questions it was the most accurate mode with reasoning off.
|
|
152
|
+
|
|
153
|
+
### Reasoning
|
|
154
|
+
|
|
155
|
+
The Luna defaults disable reasoning, which suits classification: on the benchmarks below, Luna is as accurate with reasoning off and much faster. Questions that apply explicit rules to a state are different. On [StreamDecisionBench](https://github.com/JacobLinCool/StreamDecisionBench), whose questions carry decision policies, GPT-5.6 Luna through LLM Judge answered 47% of states correctly with reasoning off and 90% with reasoning effort low, at about twice the latency. The ratings strategy cannot apply a policy across a state and should not be used for these questions. [Classification and rule-following results by mode](benchmarks/decision-modes.md).
|
|
156
|
+
|
|
157
|
+
To enable reasoning, override the default and leave room for it in the output limit:
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
provider_options: {
|
|
161
|
+
strategy: :single_request,
|
|
162
|
+
max_output_tokens: 4096,
|
|
163
|
+
chat_provider_options: { reasoning_effort: 'low' } # OpenAI
|
|
164
|
+
# chat_provider_options: { reasoning: { effort: 'low' } } # OpenRouter
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Reasoning does not fit the `:ratings` strategy, whose four-token output limit is meant for a single digit.
|
|
169
|
+
|
|
143
170
|
## How scoring works
|
|
144
171
|
|
|
145
172
|
The default `:ratings` strategy asks the model to rate every declared answer from 0 to 9. It makes one call per answer, with up to six calls running at once (`max_workers:` changes this), then applies softmax to the ratings. If a call fails after its retry, no new calls start, calls in flight finish, and the error is raised. It retries a malformed digit once. When the highest ratings tie on a Choice question, it makes a one-call JSON judgment for that question and uses its distribution. If that call also ties, it raises an error instead of choosing whichever option came first. `result.raw[:tie_breaks]` records each resolution, and token usage includes the extra call. Set `tie_breaker: :first` to use the original first-option rule, or `tie_break_max_output_tokens:` to change the tie-break response limit (default 8192).
|
|
@@ -6,24 +6,38 @@ module RubyLLM
|
|
|
6
6
|
module LLMJudge
|
|
7
7
|
class Engine
|
|
8
8
|
MAX_WORKERS = 6
|
|
9
|
+
# Luna model IDs by provider, which get temperature zero and reasoning disabled in that provider's
|
|
10
|
+
# format. Only each provider's own IDs match: an OpenAI-compatible gateway such as Bedrock, reached
|
|
11
|
+
# through :openai with an ID like "us.openai.gpt-5.6-luna", may use another request format. Pro
|
|
12
|
+
# variants are excluded: they exist to reason more.
|
|
13
|
+
LUNA_IDS = {
|
|
14
|
+
openai: /\Agpt-(?:\d+(?:\.\d+)?-)?luna(?:-latest)?\z/,
|
|
15
|
+
openrouter: %r{\A~?openai/gpt-(?:\d+(?:\.\d+)?-)?luna(?:-latest)?(?::\w+)?\z}
|
|
16
|
+
}.freeze
|
|
9
17
|
SYSTEM_INSTRUCTIONS = 'Return exactly one ASCII digit 0-9. 9 means very likely to be the correct answer; 0 means very unlikely. No explanation.'
|
|
10
18
|
SINGLE_REQUEST_INSTRUCTIONS = 'Answer all questions using only a JSON object with an "answers" field. ' \
|
|
11
19
|
'For each question, return an object mapping every supplied option ID to a ' \
|
|
12
20
|
'probability between 0 and 1. Include every option exactly once and make ' \
|
|
13
21
|
'each question\'s probabilities sum to 1. Evaluate each question independently ' \
|
|
14
|
-
'against the same state. Return no explanations or markdown.'
|
|
22
|
+
'against the same state. Return no explanations or markdown. The "answers" value ' \
|
|
23
|
+
'must be an object whose keys are the question IDs.'
|
|
24
|
+
COMMITTED_INSTRUCTIONS = 'Answer all questions using only a JSON object with an "answers" field whose keys are ' \
|
|
25
|
+
'the question IDs. For each question, give the ID of the one option that best applies. ' \
|
|
26
|
+
'Evaluate each question independently against the same state. Return no explanations ' \
|
|
27
|
+
'or markdown.'
|
|
28
|
+
STRATEGIES = %i[ratings single_request committed].freeze
|
|
15
29
|
|
|
16
30
|
def initialize(config:, model: DEFAULT_MODEL, provider_options: {}, scorer: nil, responder: nil)
|
|
17
31
|
@config = config
|
|
18
32
|
options = provider_options.transform_keys(&:to_sym)
|
|
19
33
|
allowed = %i[scoring_provider scoring_protocol temperature max_output_tokens chat_provider_options
|
|
20
34
|
max_arms max_input_bytes strategy malformed_retries tie_breaker tie_break_max_output_tokens
|
|
21
|
-
max_workers]
|
|
35
|
+
max_workers structured_output]
|
|
22
36
|
unknown = options.keys - allowed
|
|
23
37
|
raise ArgumentError, "Unknown LLMJudge provider options: #{unknown.join(', ')}" unless unknown.empty?
|
|
24
38
|
|
|
25
39
|
@strategy = options.fetch(:strategy, :ratings).to_sym
|
|
26
|
-
raise ArgumentError, 'strategy must be :ratings or :
|
|
40
|
+
raise ArgumentError, 'strategy must be :ratings, :single_request, or :committed' unless STRATEGIES.include?(@strategy)
|
|
27
41
|
@tie_breaker = options.fetch(:tie_breaker, :single_request).to_sym
|
|
28
42
|
raise ArgumentError, 'tie_breaker must be :single_request or :first' unless %i[single_request first].include?(@tie_breaker)
|
|
29
43
|
|
|
@@ -31,14 +45,15 @@ module RubyLLM
|
|
|
31
45
|
@scoring_model = model
|
|
32
46
|
raise ArgumentError, 'A model is required' unless @scoring_model.is_a?(String) && !@scoring_model.empty?
|
|
33
47
|
|
|
34
|
-
luna_defaults = @scoring_provider
|
|
35
|
-
@scoring_protocol = options.fetch(:scoring_protocol,
|
|
48
|
+
luna_defaults = LUNA_IDS.fetch(@scoring_provider, nil)&.match?(@scoring_model) || false
|
|
49
|
+
@scoring_protocol = options.fetch(:scoring_protocol,
|
|
50
|
+
luna_defaults && @scoring_provider == :openai ? :chat_completions : nil)
|
|
36
51
|
# Before 2.1, RubyLLM chats use each provider's one chat API (Chat Completions for OpenAI).
|
|
37
52
|
if !NATIVE && ![nil, :chat_completions].include?(@scoring_protocol&.to_sym)
|
|
38
53
|
raise ArgumentError, 'scoring_protocol requires RubyLLM 2.1'
|
|
39
54
|
end
|
|
40
55
|
@temperature = options.fetch(:temperature, luna_defaults ? 0 : nil)
|
|
41
|
-
@max_output_tokens = options.fetch(:max_output_tokens, @strategy == :
|
|
56
|
+
@max_output_tokens = options.fetch(:max_output_tokens, @strategy == :ratings ? 4 : 8192)
|
|
42
57
|
@tie_break_max_output_tokens = options.fetch(:tie_break_max_output_tokens, 8192)
|
|
43
58
|
chat_provider_options = options.fetch(:chat_provider_options, {})
|
|
44
59
|
raise ArgumentError, 'chat_provider_options must be a Hash' unless chat_provider_options.is_a?(Hash)
|
|
@@ -49,6 +64,8 @@ module RubyLLM
|
|
|
49
64
|
@max_input_bytes = options[:max_input_bytes]
|
|
50
65
|
@malformed_retries = options.fetch(:malformed_retries, 1)
|
|
51
66
|
@max_workers = options.fetch(:max_workers, MAX_WORKERS)
|
|
67
|
+
@structured_output = options.fetch(:structured_output, false)
|
|
68
|
+
raise ArgumentError, 'structured_output must be true or false' unless [true, false].include?(@structured_output)
|
|
52
69
|
raise ArgumentError, 'max_workers must be a positive integer' unless @max_workers.is_a?(Integer) && @max_workers.positive?
|
|
53
70
|
raise ArgumentError, 'max_output_tokens must be positive' unless @max_output_tokens.is_a?(Integer) && @max_output_tokens.positive?
|
|
54
71
|
unless @tie_break_max_output_tokens.is_a?(Integer) && @tie_break_max_output_tokens.positive?
|
|
@@ -62,11 +79,12 @@ module RubyLLM
|
|
|
62
79
|
end
|
|
63
80
|
|
|
64
81
|
@scorer = scorer || method(:score_with_model)
|
|
65
|
-
@responder = responder
|
|
82
|
+
@responder = responder
|
|
66
83
|
end
|
|
67
84
|
|
|
68
85
|
def judge(input, questions:, model:)
|
|
69
|
-
return
|
|
86
|
+
return judge_one_call(input, questions, model, :probabilities) if @strategy == :single_request
|
|
87
|
+
return judge_one_call(input, questions, model, :committed) if @strategy == :committed
|
|
70
88
|
|
|
71
89
|
jobs = build_jobs(input, questions)
|
|
72
90
|
results = run(jobs) { |job| @scorer.call(job[:prompt]) }
|
|
@@ -78,7 +96,7 @@ module RubyLLM
|
|
|
78
96
|
chosen = answer(question, softmax(ratings))
|
|
79
97
|
if question.type == :choice && ratings.count(ratings.max) > 1 && @tie_breaker == :single_request
|
|
80
98
|
# Raises if the one-call judgment also ties, after its corrective retry.
|
|
81
|
-
judgment =
|
|
99
|
+
judgment = judge_one_call(input, { question.name => question }, model, :probabilities)
|
|
82
100
|
chosen = judgment.answers.fetch(question.name)
|
|
83
101
|
tie_breaks << { question: question.name, ratings:, judgment: }
|
|
84
102
|
end
|
|
@@ -103,44 +121,98 @@ module RubyLLM
|
|
|
103
121
|
|
|
104
122
|
private
|
|
105
123
|
|
|
106
|
-
|
|
124
|
+
# One request for every question. :probabilities asks for a distribution per question;
|
|
125
|
+
# :committed asks for one option per question and returns it with probability 1.
|
|
126
|
+
def judge_one_call(input, questions, model, mode)
|
|
107
127
|
specs = validated_options(input, questions)
|
|
108
|
-
original_prompt =
|
|
128
|
+
original_prompt = one_call_prompt(input, specs)
|
|
109
129
|
prompt = original_prompt
|
|
110
130
|
attempts = []
|
|
111
131
|
loop do
|
|
112
|
-
result =
|
|
132
|
+
result = respond(prompt, mode, specs)
|
|
113
133
|
attempts << result
|
|
114
134
|
begin
|
|
115
|
-
answers, reported, normalization =
|
|
135
|
+
answers, reported, normalization = mode == :committed ? parse_committed(result.fetch(:content), specs)
|
|
136
|
+
: parse_single_request(result.fetch(:content), specs)
|
|
137
|
+
raw = { method: mode == :committed ? 'committed_answers' : 'single_request_probabilities',
|
|
138
|
+
scoring_provider: @scoring_provider, scoring_model: @scoring_model,
|
|
139
|
+
reported_probabilities: reported, attempts: attempts.size }
|
|
140
|
+
raw[:reported_totals] = normalization if normalization
|
|
116
141
|
return Types::Judgment.new(
|
|
117
|
-
answers:, model: model.id,
|
|
118
|
-
tokens: aggregate_tokens(attempts.map { |attempt| attempt.fetch(:tokens) })
|
|
119
|
-
raw: { method: 'single_request_probabilities', scoring_provider: @scoring_provider,
|
|
120
|
-
scoring_model: @scoring_model, reported_probabilities: reported,
|
|
121
|
-
reported_totals: normalization, attempts: attempts.size }
|
|
142
|
+
answers:, model: model.id, raw:,
|
|
143
|
+
tokens: aggregate_tokens(attempts.map { |attempt| attempt.fetch(:tokens) })
|
|
122
144
|
)
|
|
123
145
|
rescue RubyLLM::Error => error
|
|
124
146
|
raise if attempts.size > @malformed_retries
|
|
125
147
|
|
|
126
148
|
prompt = "#{original_prompt}\n\nThe previous answer was invalid: #{error.message}. " \
|
|
127
149
|
"Previous answer: #{result.fetch(:content).to_s[0, 4000]}\n" \
|
|
128
|
-
'Return a complete corrected JSON object with every question
|
|
150
|
+
'Return a complete corrected JSON object with every question.'
|
|
129
151
|
end
|
|
130
152
|
end
|
|
131
153
|
end
|
|
132
154
|
|
|
133
|
-
def
|
|
155
|
+
def respond(prompt, mode, specs)
|
|
156
|
+
return @responder.call(prompt) if @responder
|
|
157
|
+
|
|
158
|
+
respond_with_model(prompt, mode:, schema: @structured_output ? answer_schema(mode, specs) : nil)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# The answers object keyed by question ID, accepting a fenced reply or a list of one-question objects.
|
|
162
|
+
def reported_answers(content, specs)
|
|
134
163
|
parsed = JSON.parse(strip_code_fence(content))
|
|
135
164
|
raise Error, 'Scoring model returned a non-object response' unless parsed.is_a?(Hash)
|
|
136
165
|
|
|
137
166
|
reported = parsed.fetch('answers')
|
|
167
|
+
# Some models mirror the prompt's question list: [{"id": {...}}, ...].
|
|
168
|
+
if reported.is_a?(Array) && reported.all? { |item| item.is_a?(Hash) }
|
|
169
|
+
ids = reported.flat_map(&:keys)
|
|
170
|
+
reported = reported.reduce({}, :merge) if ids.uniq.size == ids.size
|
|
171
|
+
end
|
|
138
172
|
expected_questions = specs.map { |question, _| question.name.to_s }
|
|
139
173
|
unless reported.is_a?(Hash) && reported.keys.sort == expected_questions.sort
|
|
140
174
|
actual = reported.is_a?(Hash) ? reported.keys : reported.class.name
|
|
141
175
|
raise Error, "Question IDs must be #{expected_questions.inspect}; got #{actual.inspect}"
|
|
142
176
|
end
|
|
143
177
|
|
|
178
|
+
reported
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def parse_committed(content, specs)
|
|
182
|
+
reported = reported_answers(content, specs)
|
|
183
|
+
answers = specs.to_h do |question, options|
|
|
184
|
+
keys = options.map { |name, _| name.to_s }
|
|
185
|
+
value = reported.fetch(question.name.to_s)
|
|
186
|
+
value = { 'yes' => 'true', 'no' => 'false' }.fetch(value.to_s.downcase, value.to_s) if question.type == :probability
|
|
187
|
+
index = keys.index(value.to_s) unless value.is_a?(Hash) || value.is_a?(Array)
|
|
188
|
+
raise Error, "The answer for #{question.name} must be one of #{keys.inspect}; got #{value.inspect}" unless index
|
|
189
|
+
|
|
190
|
+
[question.name, answer(question, keys.each_index.map { |position| position == index ? 1.0 : 0.0 })]
|
|
191
|
+
end
|
|
192
|
+
[answers, reported, nil]
|
|
193
|
+
rescue JSON::ParserError, KeyError, TypeError => error
|
|
194
|
+
raise Error, "Scoring model returned invalid JSON answers: #{error.message}"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# A strict JSON Schema that admits only complete answers to these questions.
|
|
198
|
+
def answer_schema(mode, specs)
|
|
199
|
+
properties = specs.to_h do |question, options|
|
|
200
|
+
keys = options.map { |name, _| name.to_s }
|
|
201
|
+
schema = if mode == :committed
|
|
202
|
+
{ type: 'string', enum: keys }
|
|
203
|
+
else
|
|
204
|
+
{ type: 'object', properties: keys.to_h { |key| [key, { type: 'number' }] },
|
|
205
|
+
required: keys, additionalProperties: false }
|
|
206
|
+
end
|
|
207
|
+
[question.name.to_s, schema]
|
|
208
|
+
end
|
|
209
|
+
answers = { type: 'object', properties:, required: properties.keys, additionalProperties: false }
|
|
210
|
+
{ name: 'judgment', strict: true,
|
|
211
|
+
schema: { type: 'object', properties: { answers: }, required: ['answers'], additionalProperties: false } }
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def parse_single_request(content, specs)
|
|
215
|
+
reported = reported_answers(content, specs)
|
|
144
216
|
normalization = {}
|
|
145
217
|
answers = specs.to_h do |question, options|
|
|
146
218
|
distribution = reported.fetch(question.name.to_s)
|
|
@@ -172,7 +244,9 @@ module RubyLLM
|
|
|
172
244
|
raise Error, "Scoring model returned invalid JSON probabilities: #{error.message}"
|
|
173
245
|
end
|
|
174
246
|
|
|
175
|
-
|
|
247
|
+
# The state comes first: listing the questions first made Luna 6 retry more and answer less
|
|
248
|
+
# accurately on the benchmark cases.
|
|
249
|
+
def one_call_prompt(input, specs)
|
|
176
250
|
request = {
|
|
177
251
|
state: input,
|
|
178
252
|
questions: specs.map do |question, options|
|
|
@@ -280,10 +354,13 @@ module RubyLLM
|
|
|
280
354
|
end
|
|
281
355
|
end
|
|
282
356
|
|
|
357
|
+
# Luna rates poorly with reasoning on: the output limit is spent before the answer.
|
|
283
358
|
def default_chat_options(luna_defaults)
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
luna_defaults ? {
|
|
359
|
+
case @scoring_provider
|
|
360
|
+
when :openai then luna_defaults ? { store: false, reasoning_effort: 'none' } : { store: false }
|
|
361
|
+
when :openrouter then luna_defaults ? { reasoning: { enabled: false } } : {}
|
|
362
|
+
else {}
|
|
363
|
+
end
|
|
287
364
|
end
|
|
288
365
|
|
|
289
366
|
def score_with_model(prompt)
|
|
@@ -300,13 +377,16 @@ module RubyLLM
|
|
|
300
377
|
end
|
|
301
378
|
end
|
|
302
379
|
|
|
303
|
-
def respond_with_model(prompt)
|
|
380
|
+
def respond_with_model(prompt, mode:, schema: nil)
|
|
304
381
|
limit = @strategy == :ratings ? @tie_break_max_output_tokens : @max_output_tokens
|
|
305
|
-
|
|
306
|
-
|
|
382
|
+
instructions = mode == :committed ? COMMITTED_INSTRUCTIONS : SINGLE_REQUEST_INSTRUCTIONS
|
|
383
|
+
response = scoring_chat(instructions:, max_output_tokens: limit, schema:).ask(prompt)
|
|
384
|
+
# With a schema, RubyLLM parses the JSON reply into a Hash.
|
|
385
|
+
content = response.content
|
|
386
|
+
{ content: content.is_a?(String) ? content : JSON.generate(content), tokens: response.tokens }
|
|
307
387
|
end
|
|
308
388
|
|
|
309
|
-
def scoring_chat(instructions: SYSTEM_INSTRUCTIONS, max_output_tokens: @max_output_tokens)
|
|
389
|
+
def scoring_chat(instructions: SYSTEM_INSTRUCTIONS, max_output_tokens: @max_output_tokens, schema: nil)
|
|
310
390
|
context = RubyLLM::Context.new(@config)
|
|
311
391
|
chat = if NATIVE
|
|
312
392
|
context.chat(model: @scoring_model, provider: @scoring_provider,
|
|
@@ -316,15 +396,30 @@ module RubyLLM
|
|
|
316
396
|
end
|
|
317
397
|
chat.with_instructions(instructions)
|
|
318
398
|
chat.with_temperature(@temperature) unless @temperature.nil?
|
|
399
|
+
chat.with_schema(schema) if schema
|
|
319
400
|
if NATIVE
|
|
320
401
|
chat.with_max_output_tokens(max_output_tokens)
|
|
321
|
-
chat.with_provider_options(@chat_provider_options)
|
|
402
|
+
chat.with_provider_options(deep_compact(@chat_provider_options))
|
|
322
403
|
else
|
|
323
|
-
chat.with_params(**deep_merge(max_output_tokens_param(max_output_tokens), @chat_provider_options))
|
|
404
|
+
chat.with_params(**deep_compact(deep_merge(max_output_tokens_param(max_output_tokens), @chat_provider_options)))
|
|
324
405
|
end
|
|
325
406
|
chat
|
|
326
407
|
end
|
|
327
408
|
|
|
409
|
+
# A nil in chat_provider_options removes that field: a default, or before 2.1
|
|
410
|
+
# the output limit field. A hash emptied by removal is removed too.
|
|
411
|
+
def deep_compact(hash)
|
|
412
|
+
hash.each_with_object({}) do |(key, value), compacted|
|
|
413
|
+
next if value.nil?
|
|
414
|
+
|
|
415
|
+
if value.is_a?(Hash) && !value.empty?
|
|
416
|
+
value = deep_compact(value)
|
|
417
|
+
next if value.empty?
|
|
418
|
+
end
|
|
419
|
+
compacted[key] = value
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
|
|
328
423
|
# The request field each provider reads for the output limit, as RubyLLM 2.1 renders it.
|
|
329
424
|
def max_output_tokens_param(limit)
|
|
330
425
|
case @scoring_provider
|
|
@@ -7,7 +7,10 @@ module RubyLLM
|
|
|
7
7
|
'http://127.0.0.1'
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
# RubyLLM passes images as +with:+; they are not supported yet.
|
|
11
|
+
def judge(input, questions:, model:, with: [], provider_options: {})
|
|
12
|
+
raise UnsupportedAttachmentError, Array(with).first.mime_type unless Array(with).empty?
|
|
13
|
+
|
|
11
14
|
Engine.new(config:, model: model.id, provider_options:).judge(input, questions:, model:)
|
|
12
15
|
end
|
|
13
16
|
|