squishling 0.3.0 → 0.5.0
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 +133 -1
- data/README.md +63 -15
- data/docs/adapters.md +189 -0
- data/docs/configuration.md +89 -24
- data/docs/coolhand.md +66 -0
- data/docs/failures.md +129 -20
- data/docs/harnesses.md +186 -48
- data/docs/measuring-tokens.md +36 -42
- data/docs/observers.md +80 -0
- data/docs/routing.md +7 -2
- data/docs/schemas.md +33 -6
- data/docs/testing.md +183 -0
- data/docs/verifying.md +84 -0
- data/lib/squishling/adapter/error_body.rb +47 -0
- data/lib/squishling/adapter.rb +275 -0
- data/lib/squishling/adapters/openai_compatible.rb +304 -0
- data/lib/squishling/adapters/openai_options.rb +72 -0
- data/lib/squishling/adapters/ruby_llm.rb +193 -0
- data/lib/squishling/budget.rb +71 -0
- data/lib/squishling/class_methods.rb +43 -7
- data/lib/squishling/class_registry.rb +63 -0
- data/lib/squishling/configuration.rb +72 -8
- data/lib/squishling/definition.rb +95 -17
- data/lib/squishling/errors.rb +97 -12
- data/lib/squishling/harness/base.rb +136 -0
- data/lib/squishling/harness/escalation.rb +22 -0
- data/lib/squishling/harness/judged_squishsum.rb +83 -0
- data/lib/squishling/harness/option_types.rb +94 -0
- data/lib/squishling/harness/registry.rb +41 -0
- data/lib/squishling/harness/squishsum.rb +22 -0
- data/lib/squishling/harness.rb +25 -99
- data/lib/squishling/invoker.rb +28 -233
- data/lib/squishling/judge.rb +78 -21
- data/lib/squishling/llm_client.rb +73 -109
- data/lib/squishling/model_path.rb +117 -27
- data/lib/squishling/named_registry.rb +64 -0
- data/lib/squishling/observer.rb +109 -0
- data/lib/squishling/observers/coolhand.rb +86 -0
- data/lib/squishling/output_check.rb +13 -9
- data/lib/squishling/params.rb +15 -27
- data/lib/squishling/railtie.rb +10 -0
- data/lib/squishling/result.rb +37 -16
- data/lib/squishling/rotation.rb +31 -0
- data/lib/squishling/rspec.rb +18 -0
- data/lib/squishling/run/messages.rb +79 -0
- data/lib/squishling/run/rejections.rb +43 -0
- data/lib/squishling/run.rb +320 -0
- data/lib/squishling/schema.rb +10 -9
- data/lib/squishling/squawk.rb +33 -3
- data/lib/squishling/tasks/squishling.rake +18 -0
- data/lib/squishling/testing/adapter.rb +194 -0
- data/lib/squishling/testing/checks.rb +39 -0
- data/lib/squishling/testing/direct.rb +41 -0
- data/lib/squishling/testing/errors.rb +18 -0
- data/lib/squishling/testing/fixtures.rb +206 -0
- data/lib/squishling/testing/helpers.rb +71 -0
- data/lib/squishling/testing/minitest.rb +32 -0
- data/lib/squishling/testing/observer.rb +31 -0
- data/lib/squishling/testing/reply.rb +60 -0
- data/lib/squishling/testing/request.rb +75 -0
- data/lib/squishling/testing/rspec.rb +30 -0
- data/lib/squishling/testing/script.rb +189 -0
- data/lib/squishling/testing/state.rb +73 -0
- data/lib/squishling/testing/stubs.rb +112 -0
- data/lib/squishling/testing.rb +126 -0
- data/lib/squishling/verification.rb +39 -0
- data/lib/squishling/verifier.rb +179 -0
- data/lib/squishling/version.rb +1 -1
- data/lib/squishling.rb +89 -4
- metadata +53 -21
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
# One squished call, as a harness sees it (see Harness::Base): the primitives that ask the model for outputs,
|
|
5
|
+
# and everything they do for free: model, provider, and params resolution, schema and squish_validate checks,
|
|
6
|
+
# retries in the same chat, escalation, squawk reports, and Squishling's own errors when attempts run out.
|
|
7
|
+
# Each returns typed results, the same type as the deterministic path.
|
|
8
|
+
#
|
|
9
|
+
# A stage is the attempts of one escalation step (a model with its attempts:), as an Array of
|
|
10
|
+
# ModelPath::Step. Primitives take stages, or anything that declares one (a model name or a step Hash).
|
|
11
|
+
#
|
|
12
|
+
# Primitives must be called from the thread that runs the harness. Only the provider requests inside them run
|
|
13
|
+
# on other threads; parsing, validation, and every callback (squish_validate, compare:, procs) stay on this one.
|
|
14
|
+
class Run
|
|
15
|
+
include Messages
|
|
16
|
+
include Rejections
|
|
17
|
+
|
|
18
|
+
private_constant :Messages, :Rejections
|
|
19
|
+
|
|
20
|
+
# One of #samples' outputs, between rounds.
|
|
21
|
+
Sample = Struct.new(:name, :stage, :chat, :rejected, :result, :reask)
|
|
22
|
+
|
|
23
|
+
SAMPLE_NAMES = ("a".."z").to_a.freeze
|
|
24
|
+
private_constant :Sample, :SAMPLE_NAMES
|
|
25
|
+
|
|
26
|
+
attr_reader :receiver, :inputs, :steps
|
|
27
|
+
|
|
28
|
+
def initialize(definition:, receiver:, inputs:, prompt:, payload:, purpose:, harness:)
|
|
29
|
+
@definition = definition
|
|
30
|
+
@receiver = receiver
|
|
31
|
+
@inputs = inputs
|
|
32
|
+
@prompt = prompt
|
|
33
|
+
@payload = payload
|
|
34
|
+
@purpose = purpose
|
|
35
|
+
@harness = harness
|
|
36
|
+
@steps = definition.escalation_path(receiver, inputs).freeze
|
|
37
|
+
@client = LLMClient.new(definition.label, purpose:, harness: harness.type, handed_off: definition.handed_off,
|
|
38
|
+
budget: definition.budget)
|
|
39
|
+
@output_check = OutputCheck.new(definition, receiver, inputs)
|
|
40
|
+
@owner = Thread.current
|
|
41
|
+
@produced = {}.compare_by_identity
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Whether value is a result one of this run's primitives returned (not one built by hand).
|
|
45
|
+
def produced?(value)
|
|
46
|
+
@produced.key?(value)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The squished method, as "Class#method".
|
|
50
|
+
def label
|
|
51
|
+
@definition.label
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The escalation's steps grouped into stages: runs of identical consecutive steps, the attempts of one model.
|
|
55
|
+
def stages
|
|
56
|
+
@stages ||= steps.slice_when { |before, after| before != after }.map(&:freeze).freeze
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# A stage from a model name or a step Hash (model:, provider:, params:, attempts:, forward_rejected:,
|
|
60
|
+
# retry_temperature:), the way a judge step is resolved: it keeps its own provider, and its params merge over
|
|
61
|
+
# the method's.
|
|
62
|
+
# A stage (an Array of steps) is returned as is, and a single step is a stage of one attempt.
|
|
63
|
+
def stage(declaration)
|
|
64
|
+
return checked_stage([declaration]) if declaration.is_a?(ModelPath::Step)
|
|
65
|
+
|
|
66
|
+
if declaration.is_a?(Array)
|
|
67
|
+
return checked_stage(declaration) if declaration.any? && declaration.all?(ModelPath::Step)
|
|
68
|
+
|
|
69
|
+
raise ConfigurationError, "#{label}: a stage is the steps of one model, got #{declaration.inspect}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
step = Harness::OptionTypes.check(:step, declaration, label, :"harness step")
|
|
73
|
+
ModelPath.steps([step], provider: nil, params: @definition.params).freeze
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Makes each attempt of the stages in order (the escalation's steps when none are given) until an output
|
|
77
|
+
# passes the schema and squish_validate. Raises BudgetExhaustedError instead of making an attempt once the
|
|
78
|
+
# call's token budget (see Budget) is spent. Consecutive attempts on the same step continue the same chat, so the
|
|
79
|
+
# model sees what it got wrong; a new step starts a fresh chat told about the last rejected output (unless
|
|
80
|
+
# the step sets forward_rejected: false). A retry that repeats the rejected output it was just told about gives
|
|
81
|
+
# up on the rest of its step's attempts. Raises InvalidOutputError, or LLMError, when the last attempt fails.
|
|
82
|
+
def escalate(*stages)
|
|
83
|
+
owner!
|
|
84
|
+
path = stages.empty? ? steps : stages.flat_map { |declared| stage(declared) }
|
|
85
|
+
escalate_with(path, @prompt)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# One sample per stage (the attempts of one model), each asked on its own chat, each retried per its stage's
|
|
89
|
+
# attempts like #escalate, with the requests of every round sent concurrently. Returns the outputs in order.
|
|
90
|
+
# Samples are named "a", "b", ... in squawk reports.
|
|
91
|
+
def samples(stages)
|
|
92
|
+
owner!
|
|
93
|
+
raise ConfigurationError, "#{label}: samples takes an Array of stages" unless stages.is_a?(Array)
|
|
94
|
+
|
|
95
|
+
stages = stages.map { |declared| stage(declared) }
|
|
96
|
+
raise ConfigurationError, "#{label}: samples needs at least one stage" if stages.empty?
|
|
97
|
+
if stages.size > SAMPLE_NAMES.size
|
|
98
|
+
raise ConfigurationError, "#{label}: samples takes at most #{SAMPLE_NAMES.size} stages"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
if (mixed = stages.find { |sample_stage| sample_stage.uniq.size > 1 })
|
|
102
|
+
raise ConfigurationError, "#{label}: a sample is asked on one model, so its stage can't mix steps " \
|
|
103
|
+
"(got #{mixed.map(&:display_name).join(', ')})"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
samples = stages.zip(SAMPLE_NAMES).map { |sample_stage, name| Sample.new(name, sample_stage) }
|
|
107
|
+
(1..stages.map(&:size).max).each do |attempt|
|
|
108
|
+
pending = samples.reject(&:result)
|
|
109
|
+
break if pending.empty?
|
|
110
|
+
|
|
111
|
+
check_budget!(attempts: attempt - 1, models: samples.first.stage.first(attempt - 1).map(&:model),
|
|
112
|
+
rejected: pending.filter_map(&:rejected).last)
|
|
113
|
+
round(pending, attempt)
|
|
114
|
+
end
|
|
115
|
+
samples.map(&:result)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# A judge that picks between two outputs (see Judge#verdict, which returns a Judge::Verdict), or rejects
|
|
119
|
+
# both. judge: is a model name or a Hash with model:, provider:, params:, and attempts: (as an escalation
|
|
120
|
+
# step, without priority:), plus type: :chat (default) or :judgment and, for :judgment, min_confidence:.
|
|
121
|
+
# Without one, the judge is the escalation's next stage after the first. instructions: is a String, or a
|
|
122
|
+
# Proc evaluated against the receiver, replacing Harness::DEFAULT_JUDGE_INSTRUCTIONS. The judge is resolved
|
|
123
|
+
# here, so a missing one raises ConfigurationError before any request is made.
|
|
124
|
+
def judge(judge: nil, instructions: nil)
|
|
125
|
+
owner!
|
|
126
|
+
judge = judge_step(judge) unless judge.nil?
|
|
127
|
+
Judge.new(definition: @definition, receiver: @receiver, client: @client, judge:, instructions:, path: steps,
|
|
128
|
+
purpose: @purpose, payload: @payload, escalate: method(:escalate_with), log_failure: method(:log_failure),
|
|
129
|
+
guard: method(:owner!), budget_check: method(:check_budget!), harness_type: @harness.type)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Whether two outputs agree: compare (a Proc, evaluated against the receiver with the two results and the
|
|
133
|
+
# method's inputs, truthy for agreement) or, without one, whether they are exactly equal.
|
|
134
|
+
# compare is the developer's own code, so its exceptions propagate unwrapped.
|
|
135
|
+
def agree?(first, second, compare = nil)
|
|
136
|
+
owner!
|
|
137
|
+
return Schema.jsonify(first.to_h) == Schema.jsonify(second.to_h) unless compare
|
|
138
|
+
|
|
139
|
+
@receiver.instance_exec(first, second, **@inputs, &compare) ? true : false
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Logs a warning, "[Squishling] Class#method message", to Squishling.config.logger, if there is one.
|
|
143
|
+
def log(message)
|
|
144
|
+
Squishling.config.logger&.warn("[Squishling] #{label} #{message}")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
# Steps made or changed by hand get the params checks a declared step does (no reserved keys).
|
|
150
|
+
def checked_stage(stage)
|
|
151
|
+
stage.each { |step| Params.normalize(step.params, "#{label} harness step params") }
|
|
152
|
+
stage.frozen? ? stage : stage.dup.freeze
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# A judge declaration as the escalation step Judge takes: one step (no priority:), with its type.
|
|
156
|
+
def judge_step(declaration)
|
|
157
|
+
step = ModelPath.normalize_step(declaration, "#{label} judge", extras: Judge::STEP_KEYS)
|
|
158
|
+
ModelPath.single_step!(step, "#{label} judge: a judge is one step, so it")
|
|
159
|
+
|
|
160
|
+
type = step.fetch(:type, :chat)
|
|
161
|
+
type = :chat if type.nil?
|
|
162
|
+
step.merge(type: type.is_a?(String) ? type.to_sym : type)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def owner!
|
|
166
|
+
return if Thread.current.equal?(@owner)
|
|
167
|
+
|
|
168
|
+
raise ConfigurationError, "#{label}: a harness must call its run's primitives from the thread it runs on"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Makes each attempt of the path until an output passes (see #escalate). The judge's prompt (role :judge)
|
|
172
|
+
# skips squish_validate and names the judge in errors and logs.
|
|
173
|
+
def escalate_with(path, prompt)
|
|
174
|
+
chat = nil
|
|
175
|
+
rejected = nil # [raw content, errors] of the last invalid output
|
|
176
|
+
models = []
|
|
177
|
+
attempt = 1
|
|
178
|
+
|
|
179
|
+
while attempt <= path.size
|
|
180
|
+
check_budget!(attempts: models.size, models:, rejected:)
|
|
181
|
+
step = path[attempt - 1]
|
|
182
|
+
models << step.model
|
|
183
|
+
last = attempt == path.size
|
|
184
|
+
reask = !chat.nil? && step == path[attempt - 2]
|
|
185
|
+
if reask
|
|
186
|
+
message = retry_message(rejected.last)
|
|
187
|
+
else
|
|
188
|
+
chat = start_chat(step, prompt)
|
|
189
|
+
message = fresh_message(step, prompt.input, rejected)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
begin
|
|
193
|
+
response = @client.ask(chat, message, step, attempt:, attempts: path.size, role: prompt.role, reask:)
|
|
194
|
+
rescue LLMError => e
|
|
195
|
+
squawk(prompt, path, attempt, nil, e, reask:)
|
|
196
|
+
raise if last
|
|
197
|
+
|
|
198
|
+
# A failed request starts the next attempt in a fresh chat, as a new step does.
|
|
199
|
+
chat = nil
|
|
200
|
+
log_failure(prompt.role, attempt, path, e.message)
|
|
201
|
+
attempt += 1
|
|
202
|
+
next
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
result, errors = @output_check.call(response.content, prompt)
|
|
206
|
+
if errors.empty?
|
|
207
|
+
squawk(prompt, path, attempt, response, nil, reask:)
|
|
208
|
+
return produce(result, prompt)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
repeated = reask && response.content == rejected.first
|
|
212
|
+
following = next_attempt(path, attempt, repeated)
|
|
213
|
+
error = invalid_output(response, errors, models.size, models.dup, prompt.role == :judge ? "Judge" : "LLM")
|
|
214
|
+
squawk(prompt, path, attempt, response, error, reask:, repeated:, final: following.nil?)
|
|
215
|
+
raise error unless following
|
|
216
|
+
|
|
217
|
+
rejected = [response.content, error.errors]
|
|
218
|
+
log_failure(prompt.role, attempt, path, rejection_reason(error.errors, response), following:, repeated:)
|
|
219
|
+
attempt = following
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# One round of #samples: the pending samples' requests, sent concurrently, then each outcome recorded.
|
|
224
|
+
def round(pending, attempt)
|
|
225
|
+
jobs = pending.map do |sample|
|
|
226
|
+
step = sample.stage[attempt - 1]
|
|
227
|
+
message = sample_message(sample, step)
|
|
228
|
+
chat = sample.chat
|
|
229
|
+
attempts = sample.stage.size
|
|
230
|
+
reask = sample.reask
|
|
231
|
+
-> { @client.ask(chat, message, step, attempt:, attempts:, role: :sample, sample: sample.name, reask:) }
|
|
232
|
+
end
|
|
233
|
+
outcomes = @client.concurrently(jobs)
|
|
234
|
+
# A setup mistake (or anything that isn't a failed request) ends the call, whichever sample hit it.
|
|
235
|
+
fatal = outcomes.map(&:last).find { |error| error && !error.is_a?(LLMError) }
|
|
236
|
+
raise fatal if fatal
|
|
237
|
+
|
|
238
|
+
pending.zip(outcomes).each { |sample, outcome| record(sample, outcome, attempt) }
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# The next message for a sample: a retry in its chat after invalid output, otherwise a fresh chat.
|
|
242
|
+
def sample_message(sample, step)
|
|
243
|
+
sample.reask = !sample.chat.nil?
|
|
244
|
+
return retry_message(sample.rejected.last) if sample.reask
|
|
245
|
+
|
|
246
|
+
sample.chat = start_chat(step, @prompt)
|
|
247
|
+
fresh_message(step, @prompt.input, sample.rejected)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def record(sample, (response, error), attempt)
|
|
251
|
+
stage = sample.stage
|
|
252
|
+
last = attempt == stage.size
|
|
253
|
+
role = "sample #{sample.name}"
|
|
254
|
+
if error
|
|
255
|
+
squawk(@prompt, stage, attempt, nil, error, sample:, reask: sample.reask)
|
|
256
|
+
raise error if last
|
|
257
|
+
|
|
258
|
+
sample.chat = nil
|
|
259
|
+
return log_failure(role, attempt, stage, error.message)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
result, errors = @output_check.call(response.content, @prompt)
|
|
263
|
+
if errors.empty?
|
|
264
|
+
squawk(@prompt, stage, attempt, response, nil, sample:, reask: sample.reask)
|
|
265
|
+
return sample.result = produce(result, @prompt)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# A sample is one step (#samples refuses a stage that mixes them), so a repeat leaves nothing to move on to.
|
|
269
|
+
repeated = sample.reask && response.content == sample.rejected.first
|
|
270
|
+
invalid = invalid_output(response, errors, attempt, stage.first(attempt).map(&:model))
|
|
271
|
+
squawk(@prompt, stage, attempt, response, invalid, sample:, reask: sample.reask, repeated:,
|
|
272
|
+
final: last || repeated)
|
|
273
|
+
raise invalid if last || repeated
|
|
274
|
+
|
|
275
|
+
sample.rejected = [response.content, invalid.errors]
|
|
276
|
+
log_failure(role, attempt, stage, rejection_reason(invalid.errors, response))
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Remembers a result of the method's own output (a judge's verdict isn't one), so a harness can only return
|
|
280
|
+
# what a primitive produced.
|
|
281
|
+
def produce(result, prompt)
|
|
282
|
+
@produced[result] = true unless prompt.role == :judge
|
|
283
|
+
result
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def start_chat(step, prompt)
|
|
287
|
+
@client.chat(step, instructions: prompt.instructions, schema: prompt.schema.llm_schema)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Runs the observability hook, if any, with this attempt's raw output (nil when the call itself failed),
|
|
291
|
+
# the error that ended it (nil when it was accepted), and what was asked. role is nil for escalation
|
|
292
|
+
# attempts, :sample (with the sample's name) or :judge. reask is whether the attempt retried in the same chat,
|
|
293
|
+
# repeated whether it sent back the reply the attempt before it was rejected for, and final whether no attempt
|
|
294
|
+
# follows it.
|
|
295
|
+
def squawk(prompt, path, attempt, response, error, sample: nil, reask: false, repeated: false,
|
|
296
|
+
final: attempt == path.size)
|
|
297
|
+
hook = @definition.squawk
|
|
298
|
+
return unless hook && Squawk.report?(@definition.squawk_on, attempt, error, final)
|
|
299
|
+
|
|
300
|
+
step = path[attempt - 1]
|
|
301
|
+
metadata = {
|
|
302
|
+
label:, attempt:, attempts: path.size, final:, repeated:,
|
|
303
|
+
model: response&.model || step.model, provider: step.provider, params: step.request_params(reask),
|
|
304
|
+
input: prompt.input, usage: response&.usage, finish_reason: response&.finish_reason,
|
|
305
|
+
role: sample ? :sample : prompt.role, sample: sample&.name
|
|
306
|
+
}
|
|
307
|
+
Squawk.call(hook, output: response&.content, metadata:, error:)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# following is the attempt that comes next, which is not the next one when a repeated reply skips ahead.
|
|
311
|
+
def log_failure(role, attempt, path, reason, following: attempt + 1, repeated: false)
|
|
312
|
+
step = path[attempt - 1]
|
|
313
|
+
next_step = path[following - 1]
|
|
314
|
+
action = next_step == step ? "retrying" : "escalating to #{next_step.display_name}"
|
|
315
|
+
who = role ? "#{role} attempt" : "attempt"
|
|
316
|
+
reason = "it repeated the rejected reply: #{reason}" if repeated
|
|
317
|
+
log("#{who} #{attempt} of #{path.size} (#{step.display_name}) failed, #{action}: #{reason}")
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
data/lib/squishling/schema.rb
CHANGED
|
@@ -26,17 +26,19 @@ module Squishling
|
|
|
26
26
|
end
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
# The payload handed to
|
|
29
|
+
# The payload handed to the adapter (Adapter::Request#schema): always strict, without LOCAL_ONLY_KEYWORDS.
|
|
30
30
|
attr_reader :llm_schema
|
|
31
31
|
# The full JSON Schema used for validation and result typing.
|
|
32
32
|
attr_reader :json_schema
|
|
33
|
+
# The Data class results are built as, or nil when the schema isn't an object with properties.
|
|
34
|
+
attr_reader :result_class
|
|
33
35
|
|
|
34
36
|
def initialize(raw)
|
|
35
37
|
hash = self.class.jsonify(to_hash(raw))
|
|
36
38
|
wrapped = wrapped?(hash)
|
|
37
39
|
body = wrapped ? hash["schema"] : hash
|
|
38
40
|
|
|
39
|
-
# Same precedence RubyLLM uses: a top-level strict flag wins over one inside the schema.
|
|
41
|
+
# Same precedence RubyLLM 2.0 uses: a top-level strict flag wins over one inside the schema.
|
|
40
42
|
strict = hash.key?("strict") ? hash["strict"] : body["strict"]
|
|
41
43
|
raise ConfigurationError, "Squishling only supports strict output schemas (got strict: false)" if strict == false
|
|
42
44
|
|
|
@@ -48,22 +50,21 @@ module Squishling
|
|
|
48
50
|
"strict" => true
|
|
49
51
|
}.compact
|
|
50
52
|
@validator = JSONSchemer.schema(@json_schema)
|
|
53
|
+
# Built now so a property name the result can't take fails here, not on the first response.
|
|
54
|
+
@result_class = Result.build(@json_schema)
|
|
51
55
|
end
|
|
52
56
|
|
|
53
57
|
def validate(data)
|
|
54
|
-
@validator.validate(data).map { |error| error["error"] }
|
|
58
|
+
errors = @validator.validate(data).map { |error| error["error"] }
|
|
59
|
+
# A raw Hash schema can leave out type: "object"; the typed result is built from a Hash, so anything else is
|
|
60
|
+
# invalid output rather than a NoMethodError out of the builder.
|
|
61
|
+
errors.empty? && result_class && !data.is_a?(Hash) ? ["root must be an object"] : errors
|
|
55
62
|
end
|
|
56
63
|
|
|
57
64
|
def build(data, squished:)
|
|
58
65
|
result_class ? result_class.from_h(data, squished:) : data
|
|
59
66
|
end
|
|
60
67
|
|
|
61
|
-
def result_class
|
|
62
|
-
return @result_class if defined?(@result_class)
|
|
63
|
-
|
|
64
|
-
@result_class = Result.build(@json_schema)
|
|
65
|
-
end
|
|
66
|
-
|
|
67
68
|
private
|
|
68
69
|
|
|
69
70
|
def to_hash(raw)
|
data/lib/squishling/squawk.rb
CHANGED
|
@@ -1,17 +1,47 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Squishling
|
|
4
|
-
# The opt-in observability hook: a callable run after every LLM attempt
|
|
5
|
-
# it can be sent somewhere (an error tracker, a
|
|
6
|
-
# to leave the process; nothing runs unless one
|
|
4
|
+
# The opt-in observability hook: a callable run after every LLM attempt (or only the rejected and retried
|
|
5
|
+
# ones, see squawk_on:) with the attempt's raw output, so it can be sent somewhere (an error tracker, a
|
|
6
|
+
# tracing tool). It's the one sanctioned way for model output to leave the process; nothing runs unless one
|
|
7
|
+
# is configured.
|
|
7
8
|
module Squawk
|
|
8
9
|
KEYWORD_TYPES = %i[key keyreq].freeze
|
|
9
10
|
POSITIONAL_TYPES = %i[req opt].freeze
|
|
10
11
|
KEYWORD_CAPTURE_TYPES = %i[key keyreq keyrest].freeze
|
|
11
12
|
KEYWORDS = %i[output metadata error].freeze
|
|
13
|
+
# The squawk_on: conditions, each answering "does the hook run for this attempt?" from the attempt's 1-based
|
|
14
|
+
# number (the one its metadata carries), its error (nil when accepted), and whether it is the last one.
|
|
15
|
+
CONDITIONS = {
|
|
16
|
+
all: ->(_attempt, _error, _final) { true },
|
|
17
|
+
rejections: ->(_attempt, error, _final) { !error.nil? },
|
|
18
|
+
retries: ->(attempt, error, _final) { !error.nil? || attempt > 1 },
|
|
19
|
+
final: ->(_attempt, _error, final) { final }
|
|
20
|
+
}.freeze
|
|
21
|
+
ON = CONDITIONS.keys.freeze
|
|
12
22
|
|
|
13
23
|
module_function
|
|
14
24
|
|
|
25
|
+
# Which attempts reach the hook (squawk_on:): one condition or a list of them, or nil to inherit. :all is
|
|
26
|
+
# every attempt, :rejections only the ones that errored, :retries those plus any accepted attempt after the
|
|
27
|
+
# first, and :final the last attempt the escalation allows (metadata's final:), however it ended; an earlier
|
|
28
|
+
# accepted attempt is not final. A list runs the hook when any of them holds.
|
|
29
|
+
def validate_on(value, where)
|
|
30
|
+
return value if value.nil?
|
|
31
|
+
|
|
32
|
+
conditions = value.is_a?(Array) ? value : [value]
|
|
33
|
+
unless conditions.any? && conditions.all? { |condition| CONDITIONS.key?(condition) }
|
|
34
|
+
raise ConfigurationError,
|
|
35
|
+
"#{where} squawk_on must be one or a list of #{ON.map(&:inspect).join(', ')}, got #{value.inspect}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
value.is_a?(Array) ? value.dup.freeze : value
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def report?(on, attempt, error, final)
|
|
42
|
+
Array(on).any? { |condition| CONDITIONS.fetch(condition).call(attempt, error, final) }
|
|
43
|
+
end
|
|
44
|
+
|
|
15
45
|
# The hook itself, or nil/false to inherit/silence. Anything else must respond to call and take keywords
|
|
16
46
|
# only, which is checked here so a mismatch fails at setup, not inside the first attempt.
|
|
17
47
|
def validate(hook, where)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Outside Rails, load it from your Rakefile: load "squishling/tasks/squishling.rake"
|
|
4
|
+
unless Rake::Task.task_defined?("squishling:verify")
|
|
5
|
+
namespace :squishling do
|
|
6
|
+
desc "Verify every squished class without making a request (see Squishling.verify)"
|
|
7
|
+
task :verify do
|
|
8
|
+
# Only loaded classes are known, so load them all first. Rails defines :environment after loading its
|
|
9
|
+
# railties' tasks, so it can't be a prerequisite declared here.
|
|
10
|
+
Rake::Task[:environment].invoke if Rake::Task.task_defined?(:environment)
|
|
11
|
+
Rails.application.eager_load! if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
12
|
+
|
|
13
|
+
report = Squishling.verify
|
|
14
|
+
puts report
|
|
15
|
+
abort unless report.ok?
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
module Testing
|
|
5
|
+
# A Squishling adapter that answers from a script instead of a provider (see Script). Install it with
|
|
6
|
+
# Testing.install. It records every request (#requests) and groups them into the chats they belong to
|
|
7
|
+
# (#conversations), and it is safe to ask from the worker threads a harness's samples run on: everything shared
|
|
8
|
+
# is taken under one lock, and replies are chosen by (step, sample, attempt), never by arrival order.
|
|
9
|
+
class Adapter < Squishling::Adapter::Base
|
|
10
|
+
# One chat: its sample (nil unless a harness sample's) and the turns exchanged so far.
|
|
11
|
+
Conversation = Struct.new(:index, :sample, :transcript)
|
|
12
|
+
|
|
13
|
+
attr_reader :script
|
|
14
|
+
|
|
15
|
+
def initialize(script = Script.new)
|
|
16
|
+
super()
|
|
17
|
+
@script = script
|
|
18
|
+
@lock = Mutex.new
|
|
19
|
+
@requests = []
|
|
20
|
+
@conversations = []
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def judgments?
|
|
24
|
+
true
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Every request so far, in the order they arrived (Testing::Request).
|
|
28
|
+
def requests
|
|
29
|
+
@lock.synchronize { @requests.dup }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The requests of each chat, in order: a retry on the same step shares a chat, a new step starts one.
|
|
33
|
+
def conversations
|
|
34
|
+
requests.select(&:conversation).group_by(&:conversation).sort.map(&:last)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def requests_for(role: nil, sample: nil)
|
|
38
|
+
requests.select do |request|
|
|
39
|
+
(role.nil? || request.role == role) && (sample.nil? || request.sample == sample.to_s)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The models the requests went to, in order (nil for a step that names none).
|
|
44
|
+
def models
|
|
45
|
+
requests.map(&:model)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The first request's arguments: what left the process.
|
|
49
|
+
def arguments
|
|
50
|
+
requests.first&.arguments
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def complete(request)
|
|
54
|
+
outcome = @lock.synchronize { answer(request) }
|
|
55
|
+
raise outcome if outcome.is_a?(Exception)
|
|
56
|
+
|
|
57
|
+
outcome
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def judge(input, questions:, step:, label:)
|
|
61
|
+
outcome = @lock.synchronize { judged(input, questions, step, label) }
|
|
62
|
+
raise outcome if outcome.is_a?(Exception)
|
|
63
|
+
|
|
64
|
+
outcome
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def answer(request)
|
|
70
|
+
call = call_of(request)
|
|
71
|
+
outcome = outcome_for(:chat, call, request.label, request.model)
|
|
72
|
+
conversation = conversation_for(request, call[:sample])
|
|
73
|
+
conversation.transcript.push(*turns(request, outcome)) unless outcome.is_a?(Exception)
|
|
74
|
+
record(request, call, outcome, conversation.index)
|
|
75
|
+
outcome
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def judged(input, _questions, step, label)
|
|
79
|
+
call = { role: :judge, sample: nil, attempt: Observer.current&.attempt, attempts: Observer.current&.attempts,
|
|
80
|
+
model: step.model, provider: step.provider }
|
|
81
|
+
outcome = outcome_for(:judgment, call, label, step.model)
|
|
82
|
+
payload = Schema.jsonify(input)
|
|
83
|
+
@requests << Request.new(label:, type: :judgment, role: :judge, sample: nil, attempt: call[:attempt],
|
|
84
|
+
attempts: call[:attempts], model: step.model, provider: step.provider, params: step.params,
|
|
85
|
+
system_prompt: nil, output_schema: nil, messages: [], conversation: nil, payload:, reply: outcome)
|
|
86
|
+
outcome
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def outcome_for(kind, call, label, model)
|
|
90
|
+
rule = @script.rule_for(kind, call)
|
|
91
|
+
item = rule ? rule.take : Rule::EXHAUSTED
|
|
92
|
+
return unscripted(kind, call, label, model, rule) if item.equal?(Rule::EXHAUSTED)
|
|
93
|
+
|
|
94
|
+
resolve(item, label)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def resolve(item, label)
|
|
98
|
+
case item
|
|
99
|
+
when Failure then Failures.build(item.intent, label)
|
|
100
|
+
when Exception, Judgment then item
|
|
101
|
+
when Class then item <= Exception ? item.new : response(item)
|
|
102
|
+
when Reply then Squishling::Adapter::Response.new(content: item.content, model: item.model, tokens: item.usage)
|
|
103
|
+
else response(item)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def response(content)
|
|
108
|
+
Squishling::Adapter::Response.new(content:)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def unscripted(kind, call, label, model, rule)
|
|
112
|
+
aimed = call.compact.except(:attempts).map { |key, value| "#{key}: #{value.inspect}" }.join(", ")
|
|
113
|
+
why = if rule
|
|
114
|
+
"its script #{rule.empty? ? 'has no replies' : 'has no answers left (once)'}"
|
|
115
|
+
else
|
|
116
|
+
"no script covers it"
|
|
117
|
+
end
|
|
118
|
+
UnscriptedRequestError.new("#{label}: unscripted #{kind} request to #{model.inspect} (#{aimed}): #{why}. " \
|
|
119
|
+
"Scripted: #{@script.describe}")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The role, sample, and attempt of the request: from the observer that wraps it, or, when the adapter was
|
|
123
|
+
# installed without one, what the request itself shows.
|
|
124
|
+
def call_of(request)
|
|
125
|
+
observed = Observer.current
|
|
126
|
+
base = { model: request.model, provider: request.provider }
|
|
127
|
+
if observed
|
|
128
|
+
return base.merge(role: observed.role, sample: observed.sample, attempt: observed.attempt,
|
|
129
|
+
attempts: observed.attempts)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
base.merge(role: judge_schema?(request.schema) ? :judge : nil, sample: nil, attempt: nil, attempts: nil)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def judge_schema?(schema)
|
|
136
|
+
schema["schema"] == Schema.for(Judge::VERDICT_SCHEMA).llm_schema["schema"]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# A request that continues a chat sends the chat's transcript back, so it finds its conversation by it.
|
|
140
|
+
def conversation_for(request, sample)
|
|
141
|
+
history = request.messages[0...-1]
|
|
142
|
+
found = history.empty? ? nil : @conversations.reverse.find { |c| c.sample == sample && c.transcript == history }
|
|
143
|
+
found || Conversation.new(@conversations.size, sample, []).tap { |chat| @conversations << chat }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def turns(request, response)
|
|
147
|
+
[{ role: :user, content: request.messages.last.fetch(:content) }, { role: :assistant, content: response.text }]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def record(request, call, outcome, conversation)
|
|
151
|
+
@requests << Request.new(label: request.label, type: :chat, role: call[:role], sample: call[:sample]&.to_s,
|
|
152
|
+
attempt: call[:attempt], attempts: call[:attempts], model: request.model, provider: request.provider,
|
|
153
|
+
params: request.params, system_prompt: request.instructions, output_schema: request.schema["schema"],
|
|
154
|
+
messages: request.messages, conversation:, payload: Request.payload_of(request.messages), reply: outcome)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# The adapter behind Testing.forbid_provider_calls!: any request that reaches it raises ProviderCallError, which
|
|
159
|
+
# is not a Squishling::Error, so a squish_fallback can't absorb it. It remembers the calls that were attempted,
|
|
160
|
+
# for a test that rescued the error.
|
|
161
|
+
class ForbiddenAdapter < Squishling::Adapter::Base
|
|
162
|
+
def initialize
|
|
163
|
+
super
|
|
164
|
+
@lock = Mutex.new
|
|
165
|
+
@attempts = []
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# The labels ("Klass#method") of the calls that reached it.
|
|
169
|
+
def attempts
|
|
170
|
+
@lock.synchronize { @attempts.dup }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def judgments?
|
|
174
|
+
true
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def complete(request)
|
|
178
|
+
refuse(request.label)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def judge(_input, label:, **)
|
|
182
|
+
refuse(label)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
private
|
|
186
|
+
|
|
187
|
+
def refuse(label)
|
|
188
|
+
@lock.synchronize { @attempts << label }
|
|
189
|
+
raise ProviderCallError, "#{label} reached the provider, but no LLM call was expected. Script it with " \
|
|
190
|
+
"Squishling::Testing.install, or fix the code under test"
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
module Testing
|
|
5
|
+
# The assertions, independent of any test framework: each returns a failure message, or nil when it holds. The
|
|
6
|
+
# Minitest and RSpec mixins turn a message into a failure in their own way.
|
|
7
|
+
module Checks
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# The requests of a fake adapter (or an Array of them) sent exactly these arguments, as JSON would carry them.
|
|
11
|
+
def sent_arguments(source, expected, request: 0)
|
|
12
|
+
sent = requests_of(source)[request]
|
|
13
|
+
return "expected request ##{request} to have been sent, but only #{requests_of(source).size} were" unless sent
|
|
14
|
+
|
|
15
|
+
wanted = Schema.jsonify(expected)
|
|
16
|
+
return nil if sent.arguments == wanted
|
|
17
|
+
|
|
18
|
+
"expected the model to be sent arguments #{wanted.inspect}, but it was sent #{sent.arguments.inspect}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call_count(source, expected)
|
|
22
|
+
actual = requests_of(source).size
|
|
23
|
+
actual == expected ? nil : "expected #{expected} LLM call(s), but there were #{actual}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Runs the block with provider calls forbidden: a failure message when it tried to make one.
|
|
27
|
+
def no_llm_call(&)
|
|
28
|
+
attempts = Testing.no_llm_call(&)
|
|
29
|
+
return nil if attempts.empty?
|
|
30
|
+
|
|
31
|
+
"expected no LLM call, but #{attempts.uniq.join(', ')} reached the provider (#{attempts.size} request(s))"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def requests_of(source)
|
|
35
|
+
source.respond_to?(:requests) ? source.requests : Array(source)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|