squishling 0.1.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 +7 -0
- data/CHANGELOG.md +51 -0
- data/LICENSE +201 -0
- data/README.md +136 -0
- data/Rakefile +12 -0
- data/SECURITY.md +20 -0
- data/docs/configuration.md +128 -0
- data/docs/failures.md +57 -0
- data/docs/routing.md +210 -0
- data/docs/schemas.md +87 -0
- data/lib/squishling/appendices.rb +59 -0
- data/lib/squishling/class_methods.rb +168 -0
- data/lib/squishling/configuration.rb +36 -0
- data/lib/squishling/definition.rb +156 -0
- data/lib/squishling/errors.rb +26 -0
- data/lib/squishling/invoker.rb +151 -0
- data/lib/squishling/params.rb +66 -0
- data/lib/squishling/result.rb +122 -0
- data/lib/squishling/router.rb +90 -0
- data/lib/squishling/schema.rb +80 -0
- data/lib/squishling/source.rb +194 -0
- data/lib/squishling/version.rb +5 -0
- data/lib/squishling/wrapper.rb +34 -0
- data/lib/squishling.rb +65 -0
- metadata +119 -0
data/docs/routing.md
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# Routing: Ruby or LLM
|
|
2
|
+
|
|
3
|
+
Every squished method call is routed to one of two paths: the method's own Ruby implementation (the
|
|
4
|
+
*deterministic* path) or an LLM call (the *elastic* path). Both return the same validated, typed result.
|
|
5
|
+
|
|
6
|
+
## Which methods are squished
|
|
7
|
+
|
|
8
|
+
- **`call`** is squished by default, and `InvoiceParser.call(...)` is shorthand for `new.call(...)`.
|
|
9
|
+
- **Any other method** is squished once you declare it with `squish :name, ...` (see [Entry points](#entry-points)).
|
|
10
|
+
- **Methods you don't declare are never wrapped.** They're plain Ruby, though a squished method can call them,
|
|
11
|
+
and they can call `squish!` on its behalf.
|
|
12
|
+
|
|
13
|
+
## When a call goes to the LLM
|
|
14
|
+
|
|
15
|
+
A squished call runs its Ruby implementation unless one of these sends it to the LLM:
|
|
16
|
+
|
|
17
|
+
| Trigger | Declared where | Decided |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| `squish_when` (or `when:`) predicate is truthy | class, or per method | before Ruby runs |
|
|
20
|
+
| The method has no implementation: it isn't defined, or it raises `NotImplementedError` | the method body | when Ruby gives up |
|
|
21
|
+
| `squish!` | inside the method, e.g. in a `rescue` | after Ruby has partly run, see [Escalating from Ruby](#escalating-from-ruby-with-squish) |
|
|
22
|
+
|
|
23
|
+
With no predicate and a working implementation, every call runs Ruby.
|
|
24
|
+
|
|
25
|
+
The predicate receives the method's inputs as keywords and is evaluated against the instance, so it can read
|
|
26
|
+
instance state too. Accept `**` to ignore inputs you don't need.
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
class InvoiceParser
|
|
30
|
+
include Squishling
|
|
31
|
+
# ...
|
|
32
|
+
squish_when { |client_name:, **| !HARDENED_CLIENTS.include?(client_name) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def summarize(text) = raise NotImplementedError # elastic until someone writes it
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
When the Ruby implementation runs, a `Hash` it returns is validated against the schema and turned into the
|
|
39
|
+
same typed result the LLM path produces. `result(...)` (alias `squishling_result`) does the same explicitly.
|
|
40
|
+
Invalid deterministic output raises `Squishling::InvalidOutputError` too, so a hardened path can't silently
|
|
41
|
+
drift from the contract.
|
|
42
|
+
|
|
43
|
+
A `NotImplementedError` raised anywhere inside the method, including from code it calls, also routes to the
|
|
44
|
+
LLM.
|
|
45
|
+
|
|
46
|
+
Each call is routed on its own, including a squished method that calls itself on smaller inputs. A subclass
|
|
47
|
+
override that calls `super` is one call: it's routed once, at the subclass. A call to the method from its own
|
|
48
|
+
`squish_when` or `squish_fallback` (or an instructions proc) isn't routed again: it runs the Ruby
|
|
49
|
+
implementation, so a fallback can hand the input back to Ruby with `call(**inputs)`.
|
|
50
|
+
|
|
51
|
+
## Hardening a path
|
|
52
|
+
|
|
53
|
+
This is the workflow from [Elastic Software](https://everythingengineer.substack.com/p/beginners-write-software-with-ai):
|
|
54
|
+
|
|
55
|
+
1. Ship the class with instructions and a schema but no implementation. Every call goes to the LLM.
|
|
56
|
+
2. Watch which inputs carry the volume. `squished?` on each result tells you which path served it.
|
|
57
|
+
3. Write Ruby for the high-volume cases and narrow `squish_when` so only the rest go to the LLM.
|
|
58
|
+
|
|
59
|
+
Callers never change, because both paths return the same result class.
|
|
60
|
+
|
|
61
|
+
## Entry points
|
|
62
|
+
|
|
63
|
+
Squish methods other than `call` with `squish`, optionally overriding the class-level settings per method:
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
class TicketTriager
|
|
67
|
+
include Squishling
|
|
68
|
+
|
|
69
|
+
squish_context :customer_tier, :product # instance state sent alongside the arguments
|
|
70
|
+
|
|
71
|
+
squish :triage, instructions: "Assign a priority and team.", model: "claude-haiku-4-5" do
|
|
72
|
+
string :priority, enum: %w[low med high]
|
|
73
|
+
string :team
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def initialize(customer_tier:, product:, db:)
|
|
77
|
+
@customer_tier, @product, @db = customer_tier, product, db
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def triage(ticket_text) = raise NotImplementedError
|
|
81
|
+
end
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`squish` accepts:
|
|
85
|
+
|
|
86
|
+
- `instructions:` (replaces the class's)
|
|
87
|
+
- `append_instructions:` (added to the class's, see [Appending to the instructions](#appending-to-the-instructions))
|
|
88
|
+
- `output_schema:` (or a schema block)
|
|
89
|
+
- `model:`, `provider:`, and `params:` (generation params; see [Configuration](configuration.md))
|
|
90
|
+
- `when:`, a predicate proc
|
|
91
|
+
- `fallback:` (see [Failure handling](failures.md))
|
|
92
|
+
|
|
93
|
+
`squish` can come before or after the method's `def`.
|
|
94
|
+
|
|
95
|
+
## Escalating from Ruby with `squish!`
|
|
96
|
+
|
|
97
|
+
Call `squish!` inside a squished method to hand *this call* to the LLM: for example, when the Ruby parser
|
|
98
|
+
fails on an input it wasn't written for. It sends the call's arguments, as any LLM call would, and returns the
|
|
99
|
+
typed result (`squished?` is `true`, or `false` if the declared `squish_fallback` supplied it). Return that result
|
|
100
|
+
from the method.
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
class InvoiceParser
|
|
104
|
+
include Squishling
|
|
105
|
+
|
|
106
|
+
instructions "Extract invoice fields from the client's raw data."
|
|
107
|
+
append_instructions "Here is the Ruby that parses well-formed invoices, for context on the logic and goals:",
|
|
108
|
+
self
|
|
109
|
+
output_schema do
|
|
110
|
+
string :invoice_number
|
|
111
|
+
number :total
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def call(client_name:, data:)
|
|
115
|
+
parsed = AcmeParser.parse(data)
|
|
116
|
+
result(invoice_number: parsed.id, total: parsed.sum)
|
|
117
|
+
rescue AcmeParser::ParseError => e
|
|
118
|
+
squish!(append_instructions: "The Ruby parser above failed on this input; the error is in the context.",
|
|
119
|
+
context: { parse_error: e })
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`squish!` takes these overrides, all optional and all for this call only:
|
|
125
|
+
|
|
126
|
+
| Option | Effect |
|
|
127
|
+
|---|---|
|
|
128
|
+
| `context:` | A Hash sent under `"context"` with any `squish_context` values (a same-named key wins). Exceptions are sent as `{ "class", "message" }`, never their backtrace. |
|
|
129
|
+
| `append_instructions:` | Added to the declared sections; `false` (alone or first in an Array) drops them for this call |
|
|
130
|
+
| `instructions:` | Replaces the instructions |
|
|
131
|
+
| `model:`, `provider:`, `params:` | E.g. escalate to a stronger model when Ruby fails. A `provider:` needs a `model:`; `params:` merge key by key over the declared ones. |
|
|
132
|
+
|
|
133
|
+
- **The output schema can't be overridden.** The call still returns the method's result type.
|
|
134
|
+
- **Failures** go through the normal LLM path: a declared `squish_fallback` is used, otherwise
|
|
135
|
+
`InvalidOutputError` or `LLMError` is raised. When you call `squish!` from a `rescue`, Ruby sets the
|
|
136
|
+
rescued error as the `cause` of whatever is raised, so you can rescue the LLM failure and raise your own:
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
rescue AcmeParser::ParseError => e
|
|
140
|
+
begin
|
|
141
|
+
squish!(context: { parse_error: e })
|
|
142
|
+
rescue Squishling::InvalidOutputError, Squishling::LLMError
|
|
143
|
+
raise InvoiceUnreadable, "neither Ruby nor the LLM could parse invoice #{client_name}"
|
|
144
|
+
end
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
- It works anywhere beneath a squished method on the same object: in the method itself, in a helper it calls,
|
|
148
|
+
or in a parent implementation reached through `super`. Calling it anywhere else, or from a `squish_fallback`
|
|
149
|
+
(which would loop), raises `Squishling::Error`.
|
|
150
|
+
|
|
151
|
+
## Appending to the instructions
|
|
152
|
+
|
|
153
|
+
`append_instructions` adds sections to the system prompt after the instructions. It takes items, an Array of
|
|
154
|
+
them, or a block (treated as a Proc item). Each item is one of:
|
|
155
|
+
|
|
156
|
+
| Item | Sent as |
|
|
157
|
+
|---|---|
|
|
158
|
+
| a String | itself |
|
|
159
|
+
| a class or module (`self` inside a class body is that class) | its Ruby source, including bodies that reopen it in other files (see the limits below) |
|
|
160
|
+
| a method (`instance_method(:call)`, `AcmeParser.method(:parse)`) | that method's source |
|
|
161
|
+
| a Proc | evaluated against the instance on each call; it can return any of the above, an Array of them, or `nil`/`false` to add nothing (`-> { strict? && "Be strict." }`) |
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
class InvoiceParser
|
|
165
|
+
include Squishling
|
|
166
|
+
|
|
167
|
+
append_instructions "The Ruby that parses well-formed invoices:", self, AcmeParser
|
|
168
|
+
append_instructions -> { "This client's invoices are in #{currency}." }
|
|
169
|
+
|
|
170
|
+
squish :summarize, append_instructions: false do # no appendices for this method
|
|
171
|
+
string :summary
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
The same option goes in the class-wide call: `squishling append_instructions: ["...", self]`.
|
|
177
|
+
|
|
178
|
+
Sections are added down the chain: class, then subclass, then `squish :name, append_instructions:`, then
|
|
179
|
+
`squish!(append_instructions:)`. `false` drops everything declared above it, so `[false, "Only this."]`
|
|
180
|
+
replaces it.
|
|
181
|
+
|
|
182
|
+
Source is read with Ruby's own parser (Prism) the first time it's needed and cached. Some limits:
|
|
183
|
+
|
|
184
|
+
- A class's source is every `class`/`module` body that defines one of its methods, plus the one where its
|
|
185
|
+
constant is first assigned (including `Parser = Class.new do ... end`). A body that reopens the class
|
|
186
|
+
without defining a method isn't included.
|
|
187
|
+
- A class without a constant (built with `Class.new` and never assigned, nested in an anonymous module, or given
|
|
188
|
+
a temporary name) is sent method by method: each of its own `def`s, without the code around them.
|
|
189
|
+
- A class built from a `Struct.new`/`Data.define` block (`Point = Struct.new(:x) do ... end`) isn't found; reopen
|
|
190
|
+
it with `class Point` or append its methods individually.
|
|
191
|
+
- Only `def`s count. Methods made with `define_method` or `attr_*` aren't shown, and a method item made that way
|
|
192
|
+
raises `ConfigurationError`, as does code with no source file, such as code generated by `eval`.
|
|
193
|
+
|
|
194
|
+
**Appended source is sent to your provider.** Don't append classes that hold secrets in constants.
|
|
195
|
+
|
|
196
|
+
## What the LLM sees
|
|
197
|
+
|
|
198
|
+
- **System prompt:** your instructions (a String, or a Proc evaluated against the instance), then any
|
|
199
|
+
`append_instructions` sections, then a short note describing the input format.
|
|
200
|
+
- **User message:** JSON with the method's arguments, mapped to their parameter names. Any
|
|
201
|
+
`squish_context` values, and a `squish!` call's `context:`, go under `"context"`:
|
|
202
|
+
|
|
203
|
+
```json
|
|
204
|
+
{ "arguments": { "ticket_text": "API is down!" },
|
|
205
|
+
"context": { "customer_tier": "enterprise", "product": "API" } }
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
`squish_context` names are read from a method of that name if there is one, otherwise from the instance
|
|
209
|
+
variable. Only context you name is sent. Instance variables are never dumped wholesale, so API clients,
|
|
210
|
+
database connections, and secrets stay out of the prompt.
|
data/docs/schemas.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Output schemas and typed results
|
|
2
|
+
|
|
3
|
+
## Declaring a schema
|
|
4
|
+
|
|
5
|
+
`output_schema` (and `squish`) accept any of:
|
|
6
|
+
|
|
7
|
+
- a [Schematist](https://github.com/crmne/schematist) DSL block (the schema DSL RubyLLM 2.0 uses)
|
|
8
|
+
- a `Schematist::Schema` subclass (`RubyLLM::Schema` too, if your app uses the `ruby_llm-schema` shim)
|
|
9
|
+
- a raw JSON Schema `Hash`
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
output_schema do
|
|
13
|
+
string :invoice_number
|
|
14
|
+
number :total
|
|
15
|
+
array :line_items do
|
|
16
|
+
object do
|
|
17
|
+
string :description
|
|
18
|
+
number :amount
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
output_schema InvoiceSchema # class InvoiceSchema < Schematist::Schema
|
|
24
|
+
|
|
25
|
+
output_schema(
|
|
26
|
+
type: "object",
|
|
27
|
+
properties: { sentiment: { type: "string", enum: %w[positive negative neutral] } },
|
|
28
|
+
required: ["sentiment"],
|
|
29
|
+
additionalProperties: false
|
|
30
|
+
)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Strict schemas only
|
|
34
|
+
|
|
35
|
+
Squishling only supports **strict** output schemas: it always asks RubyLLM for strict structured output, and a
|
|
36
|
+
schema declaring `strict: false` raises `Squishling::ConfigurationError`. RubyLLM 2.0 on its own would send a
|
|
37
|
+
schema with optional properties non-strict; Squishling always sets the flag explicitly, so it stays strict.
|
|
38
|
+
|
|
39
|
+
With raw hashes, follow your provider's strict-mode rules (e.g. list every property in `required` and set
|
|
40
|
+
`additionalProperties: false`). The DSL generates compliant schemas for you, as long as you don't mark fields
|
|
41
|
+
`required: false`. Use `optional` instead (below). A schema the provider rejects in strict mode raises
|
|
42
|
+
`ConfigurationError`.
|
|
43
|
+
|
|
44
|
+
Not every provider enforces strict mode server-side (Anthropic, for example, doesn't receive the flag), so
|
|
45
|
+
Squishling validates every result itself. See [Failure handling](failures.md).
|
|
46
|
+
|
|
47
|
+
## Typed results
|
|
48
|
+
|
|
49
|
+
Object schemas produce `Data` result classes, and nested objects become nested `Data`. Both paths of a
|
|
50
|
+
squished method return the same class.
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
r = InvoiceParser.call(client_name: "globex", data: csv)
|
|
54
|
+
r.total # => 1250.0
|
|
55
|
+
r[:total] # => 1250.0
|
|
56
|
+
r.line_items.first.description # nested Data
|
|
57
|
+
r.to_h # deep Hash with symbol keys
|
|
58
|
+
r.squished? # => true when it came from the LLM
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
On the deterministic path, return a `Hash` or build the result with `result(...)`. Either way it's validated
|
|
62
|
+
against the schema.
|
|
63
|
+
|
|
64
|
+
## Optional vs. empty
|
|
65
|
+
|
|
66
|
+
Strict mode requires **every key to be present**, so a field can't be left out. To say "not provided", make the
|
|
67
|
+
field nullable with the DSL's `optional`. The key stays required, but its value may be `null`:
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
output_schema do
|
|
71
|
+
array :symptoms, of: :string # always a list; [] means "none"
|
|
72
|
+
optional :allergies do # [] means "none", nil means "not mentioned"
|
|
73
|
+
array of: :string
|
|
74
|
+
end
|
|
75
|
+
optional :vitals do # a typed Data object, or nil
|
|
76
|
+
object do
|
|
77
|
+
integer :heart_rate
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`optional` produces `anyOf: [<schema>, {type: "null"}]`, which is strict-compatible. Squishling types the non-null
|
|
84
|
+
branch as usual: `vitals` is a `Data` object or `nil`, and a list of objects is a list of `Data` objects. In a raw
|
|
85
|
+
JSON Schema, `type: ["array", "null"]` works too. A union with more than one non-null branch is ambiguous, so its
|
|
86
|
+
values come back as plain hashes. If you need the model to tell "none" apart from "not mentioned", say so in your
|
|
87
|
+
instructions.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
# append_instructions: extra system-prompt sections placed after the instructions, layered
|
|
5
|
+
# class -> subclass -> method -> call. Each level adds to the levels above it; `false` drops them.
|
|
6
|
+
module Appendices
|
|
7
|
+
ITEM_TYPES = [String, Module, Method, UnboundMethod, Proc].freeze
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Validates one level's items. A single item or an Array; `false` (alone or as an element) is kept as a
|
|
12
|
+
# marker that clears everything declared before it.
|
|
13
|
+
def normalize(items, label)
|
|
14
|
+
items = [items] unless items.is_a?(Array)
|
|
15
|
+
items.each { |item| check!(item, label) unless item == false }
|
|
16
|
+
items.dup.freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The items in effect once every level is concatenated in order.
|
|
20
|
+
def resolve(items)
|
|
21
|
+
items.reduce([]) { |kept, item| item == false ? [] : kept << item }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Strings verbatim, classes/modules/methods as their source, procs evaluated against the receiver
|
|
25
|
+
# (returning any of those, an Array of them, or nil/false to add nothing, so `-> { strict? && "..." }` works).
|
|
26
|
+
def render(items, receiver, label)
|
|
27
|
+
items.flat_map do |item|
|
|
28
|
+
next [render_item(item)] unless item.is_a?(Proc)
|
|
29
|
+
|
|
30
|
+
values = receiver.instance_exec(&item)
|
|
31
|
+
(values.is_a?(Array) ? values : [values]).select(&:itself).map do |value|
|
|
32
|
+
check!(value, "#{label} append_instructions proc", procs: false)
|
|
33
|
+
render_item(value)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def render_item(item)
|
|
39
|
+
item.is_a?(String) ? item : Source.render(item)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def check!(item, label, procs: true)
|
|
43
|
+
allowed = procs ? ITEM_TYPES : ITEM_TYPES - [Proc]
|
|
44
|
+
return if allowed.any? { |type| item.is_a?(type) }
|
|
45
|
+
|
|
46
|
+
raise ConfigurationError, "#{label}: append_instructions items must be Strings, classes or modules, " \
|
|
47
|
+
"#{procs ? 'methods, or procs' : 'or methods'} (got #{describe(item)})"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Simple values as written; anything else by class only, since a proc returning the wrong object (a user,
|
|
51
|
+
# a config) shouldn't copy its attributes into an error message.
|
|
52
|
+
def describe(item)
|
|
53
|
+
case item
|
|
54
|
+
when Symbol, Numeric, true, nil then item.inspect
|
|
55
|
+
else "an instance of #{item.class}"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
# Class-level DSL available to every squishling class.
|
|
5
|
+
module ClassMethods
|
|
6
|
+
DEFAULT_METHOD = :call
|
|
7
|
+
|
|
8
|
+
# Set class-wide options in one call:
|
|
9
|
+
# squishling model: "claude-sonnet-5-5", instructions: "...", output_schema: MySchema
|
|
10
|
+
# Pass provider: alongside model: for models missing from RubyLLM's registry, e.g.
|
|
11
|
+
# squishling model: "gpt-6-luna", provider: :openai
|
|
12
|
+
# params: are generation params merged over the configured defaults (see Configuration#default_params):
|
|
13
|
+
# squishling params: { temperature: 0.1, top_p: 0.9 }
|
|
14
|
+
# append_instructions: adds sections after the instructions (see #append_instructions):
|
|
15
|
+
# squishling append_instructions: ["The Ruby that handles well-formed input:", self]
|
|
16
|
+
def squishling(model: nil, provider: nil, params: nil, instructions: nil, append_instructions: nil,
|
|
17
|
+
output_schema: nil)
|
|
18
|
+
@squishling_model = model if model
|
|
19
|
+
@squishling_provider = provider if provider
|
|
20
|
+
@squishling_params = Params.normalize(params, "#{self} params") if params
|
|
21
|
+
self.instructions(instructions) if instructions
|
|
22
|
+
self.append_instructions(append_instructions) unless append_instructions.nil?
|
|
23
|
+
self.output_schema(output_schema) if output_schema
|
|
24
|
+
self
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def squishling_model
|
|
28
|
+
squishling_lookup(:@squishling_model)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def squishling_provider
|
|
32
|
+
squishling_lookup(:@squishling_provider)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Generation params merged down the inheritance chain, so a subclass overrides individual keys.
|
|
36
|
+
def squishling_params
|
|
37
|
+
squishling_inherited(:squishling_params, {}).merge(@squishling_params || {})
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The system prompt. A String, or a Proc evaluated against the instance.
|
|
41
|
+
def instructions(text = nil, &block)
|
|
42
|
+
return squishling_lookup(:@squishling_instructions) if text.nil? && block.nil?
|
|
43
|
+
|
|
44
|
+
@squishling_instructions = block || text
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Sections appended to the system prompt after the instructions, added to by subclasses, `squish`, and
|
|
48
|
+
# `squish!`. Items: Strings; a class or module (`self` for this class) or a method (`instance_method(:call)`),
|
|
49
|
+
# sent as its Ruby source; or a Proc evaluated against the instance. `false` drops inherited items.
|
|
50
|
+
# append_instructions "Here is the Ruby that parses well-formed invoices:", self
|
|
51
|
+
# append_instructions { "This client's invoices are in #{currency}." }
|
|
52
|
+
def append_instructions(*items, &block)
|
|
53
|
+
items = items.first if items.size == 1 && items.first.is_a?(Array)
|
|
54
|
+
items += [block] if block
|
|
55
|
+
(@squishling_append_instructions ||= []).concat(Appendices.normalize(items, "#{self} append_instructions"))
|
|
56
|
+
self
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Every level's items in declaration order, `false` markers included (see Appendices.resolve).
|
|
60
|
+
def squishling_append_instructions
|
|
61
|
+
squishling_inherited(:squishling_append_instructions, []) + (@squishling_append_instructions || [])
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The output format: a Schematist::Schema subclass (RubyLLM::Schema with the ruby_llm-schema shim),
|
|
65
|
+
# a raw JSON Schema Hash, or a Schematist DSL block.
|
|
66
|
+
def output_schema(schema = nil, &block)
|
|
67
|
+
return squishling_lookup(:@squishling_output_schema) if schema.nil? && block.nil?
|
|
68
|
+
|
|
69
|
+
@squishling_output_schema = block ? Schematist::Schema.create(&block) : schema
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Routing predicate, called with the method's inputs as keywords and evaluated against the
|
|
73
|
+
# instance. Truthy sends the call to the LLM; falsy runs the Ruby implementation.
|
|
74
|
+
def squish_when(&block)
|
|
75
|
+
@squishling_predicate = block
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def squishling_predicate
|
|
79
|
+
squishling_lookup(:@squishling_predicate)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Called with the error and the method's inputs (as keywords) when the LLM path fails with an
|
|
83
|
+
# InvalidOutputError or LLMError, evaluated against the instance. Its return value is used as
|
|
84
|
+
# the result (hashes are validated and typed); re-raise to propagate.
|
|
85
|
+
# squish_fallback { |error, **inputs| { priority: "medium", team: "support" } }
|
|
86
|
+
def squish_fallback(&block)
|
|
87
|
+
@squishling_fallback = block
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def squishling_fallback
|
|
91
|
+
squishling_lookup(:@squishling_fallback)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Instance state (attributes or instance variables) to send to the LLM alongside the arguments.
|
|
95
|
+
def squish_context(*names)
|
|
96
|
+
(@squishling_context_names ||= []).concat(names.map(&:to_sym))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def squishling_context_names
|
|
100
|
+
(squishling_inherited(:squishling_context_names, []) + (@squishling_context_names || [])).uniq
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Make methods elastic. Each may override the class-level settings:
|
|
104
|
+
# squish :triage, instructions: "...", model: "...", provider: :openai, when: ->(**) { true },
|
|
105
|
+
# fallback: ->(error, **) { { priority: "medium" } }, append_instructions: [...] do
|
|
106
|
+
# string :priority
|
|
107
|
+
# end
|
|
108
|
+
def squish(*names, instructions: nil, append_instructions: nil, output_schema: nil, model: nil, provider: nil,
|
|
109
|
+
params: nil, when: nil, fallback: nil, &schema_block)
|
|
110
|
+
schema = schema_block ? Schematist::Schema.create(&schema_block) : output_schema
|
|
111
|
+
params &&= Params.normalize(params, "#{self} squish params")
|
|
112
|
+
unless append_instructions.nil?
|
|
113
|
+
append_instructions = Appendices.normalize(append_instructions, "#{self} squish append_instructions")
|
|
114
|
+
end
|
|
115
|
+
options = { instructions:, append_instructions:, output_schema: schema, model:, provider:, params:,
|
|
116
|
+
predicate: binding.local_variable_get(:when), fallback: }.compact
|
|
117
|
+
|
|
118
|
+
names.map(&:to_sym).each do |name|
|
|
119
|
+
(@squishling_methods ||= {})[name] = options
|
|
120
|
+
@squishling_wrapper.wrap(name)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def squished_methods
|
|
125
|
+
squishling_inherited(:squished_methods, { DEFAULT_METHOD => {} }).merge(@squishling_methods || {})
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def squishling_definition(name)
|
|
129
|
+
options = squished_methods.fetch(name.to_sym) { raise Error, "#{self}##{name} is not squished" }
|
|
130
|
+
Definition.new(klass: self, name: name.to_sym, **options)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Service-object shortcut: InvoiceParser.call(...) == InvoiceParser.new.call(...)
|
|
134
|
+
def call(...)
|
|
135
|
+
new.call(...)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def inherited(subclass)
|
|
139
|
+
super
|
|
140
|
+
subclass.send(:squishling_install_wrapper)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
# Each class gets its own prepended wrapper so overrides in subclasses are routed too.
|
|
146
|
+
def squishling_install_wrapper
|
|
147
|
+
@squishling_wrapper = Wrapper.new
|
|
148
|
+
prepend(@squishling_wrapper)
|
|
149
|
+
|
|
150
|
+
squished_methods.each_key { |name| @squishling_wrapper.wrap(name) }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# The superclass's merged setting, or the default at the top of the chain.
|
|
154
|
+
def squishling_inherited(reader, default)
|
|
155
|
+
superclass.respond_to?(reader) ? superclass.public_send(reader) : default
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def squishling_lookup(ivar)
|
|
159
|
+
klass = self
|
|
160
|
+
while klass.is_a?(ClassMethods)
|
|
161
|
+
return klass.instance_variable_get(ivar) if klass.instance_variable_defined?(ivar)
|
|
162
|
+
|
|
163
|
+
klass = klass.superclass
|
|
164
|
+
end
|
|
165
|
+
nil
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
class Configuration
|
|
5
|
+
# Model used by every squishling class that doesn't declare its own.
|
|
6
|
+
# When nil, RubyLLM's own default model is used.
|
|
7
|
+
attr_accessor :default_model
|
|
8
|
+
|
|
9
|
+
# Provider for default_model (e.g. :openai). Only needed for models missing from
|
|
10
|
+
# RubyLLM's registry.
|
|
11
|
+
attr_accessor :default_provider
|
|
12
|
+
|
|
13
|
+
# Generation params applied to every call, overridable per class and per method.
|
|
14
|
+
# :temperature and :thinking ({ effort:, budget: }) map to RubyLLM's with_temperature and
|
|
15
|
+
# with_thinking; any other key (top_p, max_tokens, seed, ...) is passed to the provider as-is.
|
|
16
|
+
attr_reader :default_params
|
|
17
|
+
|
|
18
|
+
# How many times to re-ask the LLM when its output fails schema validation.
|
|
19
|
+
attr_accessor :max_retries
|
|
20
|
+
|
|
21
|
+
# Optional Logger for routing decisions.
|
|
22
|
+
attr_accessor :logger
|
|
23
|
+
|
|
24
|
+
def initialize
|
|
25
|
+
@default_model = nil
|
|
26
|
+
@default_provider = nil
|
|
27
|
+
@default_params = {}
|
|
28
|
+
@max_retries = 1
|
|
29
|
+
@logger = nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def default_params=(params)
|
|
33
|
+
@default_params = Params.normalize(params, "default_params")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|