inquirex-llm 0.4.1 → 0.6.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/README.md +149 -97
- data/lib/inquirex/llm/adapter.rb +40 -1
- data/lib/inquirex/llm/anthropic_adapter.rb +3 -3
- data/lib/inquirex/llm/dsl/flow_builder.rb +49 -28
- data/lib/inquirex/llm/dsl/llm_step_builder.rb +143 -18
- data/lib/inquirex/llm/node.rb +9 -7
- data/lib/inquirex/llm/null_adapter.rb +17 -6
- data/lib/inquirex/llm/openai_adapter.rb +3 -3
- data/lib/inquirex/llm/schema.rb +77 -30
- data/lib/inquirex/llm/version.rb +1 -1
- data/lib/inquirex/llm.rb +11 -8
- metadata +7 -15
- data/.relaxed_rubocop.yml +0 -153
- data/.rubocop_todo.yml +0 -28
- data/.secrets.baseline +0 -127
- data/Rakefile +0 -36
- data/docs/badges/coverage_badge.svg +0 -21
- data/justfile +0 -69
- data/lefthook.yml +0 -35
- data/sig/inquirex/llm.rbs +0 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 00b1d649377bb6ff1281a8fd3af730995b86531e44c930cb3c9a267b73e5efe0
|
|
4
|
+
data.tar.gz: '0478c2970b4d501ee3b9b2ed4dcdd2d620fbc99c522ac863e3ac772a4f75dce6'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a89a5e3ec65fab4be526a2d8bc730c8bb89bfe9b7945ef900fb987546765c29600219e1959dfb9011a43916658feb56f1f5b70625d3645a26aa75bbbe290ebdb
|
|
7
|
+
data.tar.gz: b9c05000c0b31721f3c00d425b0e525ef309540335d08c10c5c69ba2a6089fab2bc2d10ce53da141f62f1735a631c993b798ed8a7353c50cfb225b4635d59697
|
data/README.md
CHANGED
|
@@ -1,42 +1,31 @@
|
|
|
1
|
-
[](https://github.com/inquirex/inquirex-llm/actions/workflows/main.yml)
|
|
1
|
+
[](https://badge.fury.io/rb/inquirex-llm) [](https://github.com/inquirex/inquirex-llm/actions/workflows/main.yml) 
|
|
2
2
|
|
|
3
3
|
# inquirex-llm
|
|
4
4
|
|
|
5
5
|
LLM integration verbs for the [Inquirex](https://github.com/inquirex/inquirex) questionnaire engine.
|
|
6
6
|
|
|
7
|
-
Extends the core DSL with
|
|
7
|
+
Extends the core DSL with a server-side `extract` verb (alias: `clarify`) that turns free-text answers into structured data via LLM processing. Ships with a pluggable adapter interface and a `NullAdapter` for testing. (`describe`, `summarize`, and `detour` are temporarily parked.)
|
|
8
8
|
|
|
9
9
|
`inquirex` is a pure Ruby, declarative, rules-driven questionnaire engine for building conditional intake forms, qualification wizards, and branching surveys.
|
|
10
10
|
|
|
11
11
|
> [!IMPORTANT]
|
|
12
12
|
>
|
|
13
|
-
> Note that `
|
|
14
|
-
>
|
|
15
|
-
> `nquirex` is the base gem of the ecosystem that contains:
|
|
13
|
+
> Note that `inquirex-llm` is part of an entire ecosystem that contains:
|
|
16
14
|
>
|
|
17
15
|
> - [`inquirex`](https://github.com/inquirex/inquirex)
|
|
18
16
|
> - [`inquirex-llm`](https://github.com/inquirex/inquirex-llm)
|
|
19
17
|
> - [`inquirex-tty`](https://github.com/inquirex/inquirex-)
|
|
20
|
-
> - [`inquirex-js`](https://github.com/inquirex/inquirex-js)
|
|
18
|
+
> - [`inquirex-js`](https://github.com/inquirex/inquirex-js) (`npmjs` module [`@kigster/inquirex-js`](https://www.npmjs.com/package/@kigster/inquirex-js))
|
|
21
19
|
>
|
|
22
|
-
> For a presentation
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
- Version: `0.1.0`
|
|
27
|
-
- Ruby: `>= 4.0.0`
|
|
28
|
-
- Test suite: `111 examples, 0 failures`
|
|
29
|
-
- Depends on: `inquirex` (core gem)
|
|
30
|
-
|
|
31
|
-
## Installation
|
|
32
|
-
|
|
33
|
-
```ruby
|
|
34
|
-
gem "inquirex-llm"
|
|
35
|
-
```
|
|
20
|
+
> For a presentation about these gems and what they do please watch the [RubySF presentation](https://www.youtube.com/watch?v=iaoKW7Ap3_M&t=1s) and you can also [view the slides from the presentation](https://reinvent.one/images/talks/pdfs/2026.inquirex.pdf).
|
|
21
|
+
>
|
|
22
|
+
> Finally, the SaaS application [qualified.at](https://qualified.at) allows users to leverage the ecosystem by creating their own custom lead intake flows and integrating them on their own sites.
|
|
36
23
|
|
|
37
24
|
## Usage
|
|
38
25
|
|
|
39
|
-
`require "inquirex-llm"` injects the LLM verbs into the core `Inquirex.define` DSL.
|
|
26
|
+
`require "inquirex-llm"` injects the LLM verbs into the core `Inquirex.define` DSL.
|
|
27
|
+
|
|
28
|
+
No separate entry point needed.
|
|
40
29
|
|
|
41
30
|
```ruby
|
|
42
31
|
require "inquirex"
|
|
@@ -51,7 +40,7 @@ definition = Inquirex.define id: "tax-intake-2026", version: "1.0.0" do
|
|
|
51
40
|
transition to: :extracted
|
|
52
41
|
end
|
|
53
42
|
|
|
54
|
-
|
|
43
|
+
extract :extracted do
|
|
55
44
|
from :description
|
|
56
45
|
prompt "Extract structured business information from the description."
|
|
57
46
|
schema industry: :string,
|
|
@@ -60,15 +49,9 @@ definition = Inquirex.define id: "tax-intake-2026", version: "1.0.0" do
|
|
|
60
49
|
estimated_revenue: :currency
|
|
61
50
|
model :claude_sonnet
|
|
62
51
|
temperature 0.2
|
|
63
|
-
transition to: :summary
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
summarize :summary do
|
|
67
|
-
from_all
|
|
68
|
-
prompt "Summarize this client's tax situation and flag complexity concerns."
|
|
69
52
|
transition to: :done
|
|
70
53
|
end
|
|
71
|
-
|
|
54
|
+
|
|
72
55
|
say :done do
|
|
73
56
|
text "Thank you! We'll be in touch."
|
|
74
57
|
end
|
|
@@ -77,14 +60,14 @@ end
|
|
|
77
60
|
|
|
78
61
|
All core verbs (`ask`, `say`, `header`, `btw`, `warning`, `confirm`) and widget hints work alongside LLM verbs in the same `Inquirex.define` block.
|
|
79
62
|
|
|
80
|
-
## LLM Verbs
|
|
63
|
+
## Currently Supported LLM Verbs
|
|
81
64
|
|
|
82
|
-
### `clarify`
|
|
65
|
+
### `extract` (alias: `clarify`)
|
|
83
66
|
|
|
84
|
-
Extract structured data from a free-text answer. Requires `from
|
|
67
|
+
Extract structured data from a free-text answer. Requires `from` (or `from_all`), `prompt`, and `schema`. The stored/serialized verb is always `"extract"`; `clarify` is a DSL-only alias.
|
|
85
68
|
|
|
86
69
|
```ruby
|
|
87
|
-
|
|
70
|
+
extract :business_extracted do
|
|
88
71
|
from :business_description
|
|
89
72
|
prompt "Extract structured business information."
|
|
90
73
|
schema industry: :string, employee_count: :integer, revenue: :currency
|
|
@@ -95,57 +78,89 @@ clarify :business_extracted do
|
|
|
95
78
|
end
|
|
96
79
|
```
|
|
97
80
|
|
|
98
|
-
|
|
81
|
+
## Schema: Question References (preferred)
|
|
99
82
|
|
|
100
|
-
|
|
83
|
+
Most extract schemas exist to pre-fill questions asked later in the same flow. Declaring
|
|
84
|
+
those fields twice — once in the schema, once in the question — invites drift, and worse:
|
|
85
|
+
a hand-typed `income_types: :multi_enum` gives the LLM no idea which values are legal, so
|
|
86
|
+
its answers won't match the question's options.
|
|
87
|
+
|
|
88
|
+
Instead, pass the schema as a list of question ids:
|
|
101
89
|
|
|
102
90
|
```ruby
|
|
103
|
-
|
|
104
|
-
from :
|
|
105
|
-
prompt "
|
|
106
|
-
|
|
91
|
+
extract :extracted do
|
|
92
|
+
from :description
|
|
93
|
+
prompt "Extract the client's filing status, dependents, and income types."
|
|
94
|
+
schema :filing_status, :dependents, :income_types
|
|
95
|
+
transition to: :filing_status
|
|
107
96
|
end
|
|
97
|
+
|
|
98
|
+
ask :filing_status do
|
|
99
|
+
type :enum
|
|
100
|
+
question "Filing status?"
|
|
101
|
+
options({ "single" => "Single", "mfj" => "Married Filing Jointly" })
|
|
102
|
+
transition to: :dependents
|
|
103
|
+
end
|
|
104
|
+
# ...
|
|
108
105
|
```
|
|
109
106
|
|
|
110
|
-
|
|
107
|
+
Each symbol is resolved against the flow at definition time — references may point
|
|
108
|
+
**forward** to questions defined after the extract step. The gem looks up the question's
|
|
109
|
+
declared type, and for `:enum` / `:multi_enum` questions folds the exhaustive list of
|
|
110
|
+
allowed option values into the JSON schema sent to the LLM. The adapters then instruct
|
|
111
|
+
the model to answer using only those values, so extracted answers always match the
|
|
112
|
+
downstream question's options (and `Engine#prefill!` can skip the question).
|
|
111
113
|
|
|
112
|
-
|
|
114
|
+
A symbol that matches no `ask`/`confirm` step in the flow fails validation with
|
|
115
|
+
`Inquirex::LLM::Errors::DefinitionError` — as do references to display-only steps and
|
|
116
|
+
other LLM steps.
|
|
117
|
+
|
|
118
|
+
Both forms compose. Use keywords for output fields that have no corresponding question:
|
|
113
119
|
|
|
114
120
|
```ruby
|
|
115
|
-
|
|
116
|
-
from_all
|
|
117
|
-
prompt "Summarize this client's tax situation."
|
|
118
|
-
transition to: :review
|
|
119
|
-
end
|
|
121
|
+
schema :filing_status, :income_types, confidence: :decimal
|
|
120
122
|
```
|
|
121
123
|
|
|
122
|
-
### `
|
|
124
|
+
### `prompt :auto`
|
|
123
125
|
|
|
124
|
-
|
|
126
|
+
When the schema is built from question references, the schema already tells the LLM the
|
|
127
|
+
field names, types, and allowed values — the main thing a hand-written prompt still adds
|
|
128
|
+
is the questions' own wording. `prompt :auto` generates exactly that at definition time:
|
|
125
129
|
|
|
126
130
|
```ruby
|
|
127
|
-
|
|
131
|
+
extract :extracted do
|
|
128
132
|
from :description
|
|
129
|
-
prompt
|
|
130
|
-
schema
|
|
131
|
-
transition to: :
|
|
133
|
+
prompt :auto
|
|
134
|
+
schema :filing_status, :dependents, :income_types
|
|
135
|
+
transition to: :filing_status
|
|
132
136
|
end
|
|
133
137
|
```
|
|
134
138
|
|
|
139
|
+
The generated prompt enumerates each referenced question's text ("- filing_status: What
|
|
140
|
+
is your filing status for 2025?" …), lists explicit keyword fields by name and type, and
|
|
141
|
+
instructs the model to leave unsupported fields empty. Generation happens at build time,
|
|
142
|
+
so the wire format and adapters always see a concrete prompt string — `:auto` never
|
|
143
|
+
leaves the DSL. It requires at least one question reference; with only explicit
|
|
144
|
+
`key: :type` fields there is no question wording to generate from, and validation fails.
|
|
145
|
+
|
|
146
|
+
Write the prompt by hand when you need domain framing the questions don't carry
|
|
147
|
+
("for tax filing purposes", "map S-Corp to s_corp") — an explicit prompt always wins.
|
|
148
|
+
|
|
135
149
|
## DSL Methods (inside LLM verb blocks)
|
|
136
150
|
|
|
137
|
-
| Method
|
|
138
|
-
|
|
139
|
-
| `prompt "..."` | LLM prompt template | Always
|
|
140
|
-
| `schema
|
|
141
|
-
| `
|
|
142
|
-
| `
|
|
143
|
-
| `
|
|
144
|
-
| `
|
|
145
|
-
| `
|
|
146
|
-
| `
|
|
147
|
-
| `
|
|
148
|
-
| `
|
|
151
|
+
| Method | Purpose | Required |
|
|
152
|
+
| ------------------------------- | ---------------------------------------------------- | ----------------------------- |
|
|
153
|
+
| `prompt "..."` / `prompt :auto` | LLM prompt template, or generated from question refs | Always |
|
|
154
|
+
| `schema :question_id, ...` | Fields resolved from questions (types + options) | `extract` (this or keywords) |
|
|
155
|
+
| `schema key: :type, ...` | Explicit field => type pairs | `extract` (this or refs) |
|
|
156
|
+
| `from :step_id` | Source step(s) whose answers feed the LLM | `extract` (or use `from_all`) |
|
|
157
|
+
| `from_all` | Pass all collected answers to the LLM | Alternative to `from` |
|
|
158
|
+
| `model :claude_sonnet` | Optional model hint for the adapter | No |
|
|
159
|
+
| `temperature 0.3` | Optional sampling temperature | No |
|
|
160
|
+
| `max_tokens 1024` | Optional max output tokens | No |
|
|
161
|
+
| `fallback { \|answers\| ... }` | Server-side fallback (stripped from JSON) | No |
|
|
162
|
+
| `transition to: :step` | Conditional transition (same as core) | No |
|
|
163
|
+
| `skip_if rule` | Skip step when condition is true | No |
|
|
149
164
|
|
|
150
165
|
## Engine Integration
|
|
151
166
|
|
|
@@ -176,16 +191,14 @@ result = adapter.call(engine.current_step)
|
|
|
176
191
|
|
|
177
192
|
## Built-in Adapters
|
|
178
193
|
|
|
179
|
-
| Class
|
|
180
|
-
|
|
181
|
-
| `Inquirex::LLM::NullAdapter`
|
|
182
|
-
| `Inquirex::LLM::AnthropicAdapter`
|
|
183
|
-
| `Inquirex::LLM::OpenAIAdapter`
|
|
194
|
+
| Class | Provider | API | Auth | Key env var |
|
|
195
|
+
| --------------------------------------- | --------- | ---------------------------------- | ------------------------- | ------------------- |
|
|
196
|
+
| `Inquirex::LLM::NullAdapter` | — | none (placeholders) | none | — |
|
|
197
|
+
| `Inquirex::LLM::AnthropicAdapter` | Anthropic | `/v1/messages` | `x-api-key` header | `ANTHROPIC_API_KEY` |
|
|
198
|
+
| `Inquirex::LLM::OpenAIAdapter` | OpenAI | `/v1/chat/completions` (JSON mode) | `Authorization: Bearer …` | `OPENAI_API_KEY` |
|
|
199
|
+
| `Inquirex::LLM::LittleLLMAdapter` (TBD) | Any | OpenAI Compatible API | OpenAI Compatible Auth | Provider Specific |
|
|
184
200
|
|
|
185
|
-
|
|
186
|
-
declared `schema` into the system prompt as a strict JSON contract, and raise
|
|
187
|
-
`Inquirex::LLM::Errors::AdapterError` on HTTP / parse failures and
|
|
188
|
-
`SchemaViolationError` when the model's output is missing declared fields.
|
|
201
|
+
The Anthropic and OpenAI adapters use `net/http` (stdlib, no extra dependency), inject the declared `schema` into the system prompt as a strict JSON contract, and raise `Inquirex::LLM::Errors::AdapterError` on HTTP / parse failures and `SchemaViolationError` when the model's output is missing declared fields.
|
|
189
202
|
|
|
190
203
|
### AnthropicAdapter
|
|
191
204
|
|
|
@@ -196,8 +209,7 @@ adapter = Inquirex::LLM::AnthropicAdapter.new(
|
|
|
196
209
|
)
|
|
197
210
|
```
|
|
198
211
|
|
|
199
|
-
Recognized `model :symbol` values in the DSL: `:claude_sonnet`,
|
|
200
|
-
`:claude_haiku`, `:claude_opus` (mapped to the current concrete model ids).
|
|
212
|
+
Recognized `model :symbol` values in the DSL: `:claude_sonnet`, `:claude_haiku`, `:claude_opus` (mapped to the current concrete model ids).
|
|
201
213
|
|
|
202
214
|
### OpenAIAdapter
|
|
203
215
|
|
|
@@ -208,18 +220,11 @@ adapter = Inquirex::LLM::OpenAIAdapter.new(
|
|
|
208
220
|
)
|
|
209
221
|
```
|
|
210
222
|
|
|
211
|
-
Uses Chat Completions with `response_format: { type: "json_object" }` so the
|
|
212
|
-
model is constrained to return valid JSON. Recognized DSL symbols: `:gpt_4o`,
|
|
213
|
-
`:gpt_4o_mini`, `:gpt_4_1`, `:gpt_4_1_mini`. For cross-provider portability,
|
|
214
|
-
the adapter also accepts the Claude symbols (`:claude_sonnet` → `gpt-4o` etc.)
|
|
215
|
-
so a flow file that says `model :claude_sonnet` runs unchanged against either
|
|
216
|
-
provider.
|
|
223
|
+
Uses Chat Completions with `response_format: { type: "json_object" }` so the model is constrained to return valid JSON. Recognized DSL symbols: `:gpt_4o`, `:gpt_4o_mini`, `:gpt_4_1`, `:gpt_4_1_mini`. For cross-provider portability, the adapter also accepts the Claude symbols (`:claude_sonnet` → `gpt-4o` etc.) so a flow file that says `model :claude_sonnet` runs unchanged against either provider.
|
|
217
224
|
|
|
218
225
|
## LLM-assisted Pre-fill Pattern
|
|
219
226
|
|
|
220
|
-
A common use case: ask *one* open-ended question, let the LLM extract answers
|
|
221
|
-
for *many* downstream questions, and only prompt the user for what the LLM
|
|
222
|
-
couldn't determine. This is what the core engine's `Engine#prefill!` is for:
|
|
227
|
+
A common use case: ask *one* open-ended question, let the LLM extract answers for *many* downstream questions, and only prompt the user for what the LLM couldn't determine. This is what the core engine's `Engine#prefill!` is for:
|
|
223
228
|
|
|
224
229
|
```ruby
|
|
225
230
|
definition = Inquirex.define id: "tax-intake" do
|
|
@@ -231,7 +236,7 @@ definition = Inquirex.define id: "tax-intake" do
|
|
|
231
236
|
transition to: :extracted
|
|
232
237
|
end
|
|
233
238
|
|
|
234
|
-
|
|
239
|
+
extract :extracted do
|
|
235
240
|
from :describe
|
|
236
241
|
prompt "Extract: filing_status, dependents, income_types, state_filing."
|
|
237
242
|
schema filing_status: :string,
|
|
@@ -256,7 +261,7 @@ definition = Inquirex.define id: "tax-intake" do
|
|
|
256
261
|
skip_if not_empty(:dependents)
|
|
257
262
|
transition to: :income_types
|
|
258
263
|
end
|
|
259
|
-
# …and so on for every field in the
|
|
264
|
+
# …and so on for every field in the extract schema
|
|
260
265
|
end
|
|
261
266
|
|
|
262
267
|
engine = Inquirex::Engine.new(definition)
|
|
@@ -272,12 +277,7 @@ engine.prefill!(result) # splats into top-level answers
|
|
|
272
277
|
# whichever field the LLM couldn't fill in.
|
|
273
278
|
```
|
|
274
279
|
|
|
275
|
-
`Engine#prefill!` is non-destructive (won't clobber an answer the user already
|
|
276
|
-
gave), ignores `nil`/empty values so they don't spuriously trigger
|
|
277
|
-
`not_empty`, and auto-advances past any step whose `skip_if` now evaluates
|
|
278
|
-
true. See [examples/09_tax_preparer_llm.rb](../inquirex-tty/examples/09_tax_preparer_llm.rb)
|
|
279
|
-
for a complete runnable flow, or the repo-level `demo_llm_intake.rb` for a
|
|
280
|
-
scripted end-to-end walkthrough.
|
|
280
|
+
`Engine#prefill!` is non-destructive (won't clobber an answer the user already gave), ignores `nil`/empty values so they don't spuriously trigger `not_empty`, and auto-advances past any step whose `skip_if` now evaluates true. See [examples/09_tax_preparer_llm.rb](../inquirex-tty/examples/09_tax_preparer_llm.rb) for a complete runnable flow, or the repo-level `demo_llm_intake.rb` for a scripted end-to-end walkthrough.
|
|
281
281
|
|
|
282
282
|
## JSON Serialization
|
|
283
283
|
|
|
@@ -285,15 +285,19 @@ LLM steps serialize with `"requires_server": true` so the JS widget knows to rou
|
|
|
285
285
|
|
|
286
286
|
```json
|
|
287
287
|
{
|
|
288
|
-
"verb": "
|
|
288
|
+
"verb": "extract",
|
|
289
289
|
"requires_server": true,
|
|
290
|
-
"transitions": [{ "to": "
|
|
290
|
+
"transitions": [{ "to": "next_step", "requires_server": true }],
|
|
291
291
|
"llm": {
|
|
292
292
|
"prompt": "Extract structured business information.",
|
|
293
293
|
"schema": {
|
|
294
294
|
"industry": "string",
|
|
295
295
|
"employee_count": "integer",
|
|
296
|
-
"revenue": "currency"
|
|
296
|
+
"revenue": "currency",
|
|
297
|
+
"income_types": {
|
|
298
|
+
"type": "multi_enum",
|
|
299
|
+
"values": ["W2", "business", "crypto"]
|
|
300
|
+
}
|
|
297
301
|
},
|
|
298
302
|
"from_steps": ["business_description"],
|
|
299
303
|
"model": "claude_sonnet",
|
|
@@ -303,7 +307,10 @@ LLM steps serialize with `"requires_server": true` so the JS widget knows to rou
|
|
|
303
307
|
}
|
|
304
308
|
```
|
|
305
309
|
|
|
306
|
-
|
|
310
|
+
Unconstrained fields serialize as a plain type string; fields resolved from `:enum` /
|
|
311
|
+
`:multi_enum` questions serialize as `{ "type": ..., "values": [...] }` so any consumer
|
|
312
|
+
(the JS widget, a server adapter) sees the full contract. Fallback procs are stripped
|
|
313
|
+
from JSON (server-side only).
|
|
307
314
|
|
|
308
315
|
## Custom Adapter
|
|
309
316
|
|
|
@@ -328,6 +335,47 @@ end
|
|
|
328
335
|
|
|
329
336
|
The base class provides `#source_answers` (gathers relevant answers) and `#validate_output!` (checks schema conformance).
|
|
330
337
|
|
|
338
|
+
## Future Possible LLM Verbs
|
|
339
|
+
|
|
340
|
+
### `describe`
|
|
341
|
+
|
|
342
|
+
Generate natural-language text from structured data. Requires `from` and `prompt`. No schema needed.
|
|
343
|
+
|
|
344
|
+
```ruby
|
|
345
|
+
describe :business_narrative do
|
|
346
|
+
from :business_extracted
|
|
347
|
+
prompt "Write a brief narrative of this business for the intake report."
|
|
348
|
+
transition to: :next_step
|
|
349
|
+
end
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
### `summarize`
|
|
353
|
+
|
|
354
|
+
Produce a summary of all or selected answers. Use `from_all` to pass everything, or `from` to select specific steps.
|
|
355
|
+
|
|
356
|
+
```ruby
|
|
357
|
+
summarize :intake_summary do
|
|
358
|
+
from_all
|
|
359
|
+
prompt "Summarize this client's tax situation."
|
|
360
|
+
transition to: :review
|
|
361
|
+
end
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
### `detour` (parked)
|
|
365
|
+
|
|
366
|
+
Dynamically generate follow-up questions based on an answer. The server adapter handles presenting the generated questions and collecting responses. Requires `from`, `prompt`, and `schema`.
|
|
367
|
+
|
|
368
|
+
```ruby
|
|
369
|
+
detour :followup do
|
|
370
|
+
from :description
|
|
371
|
+
prompt "Generate 2-3 follow-up questions to clarify the tax situation."
|
|
372
|
+
schema questions: :array, answers: :hash
|
|
373
|
+
transition to: :next_step
|
|
374
|
+
end
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
##
|
|
378
|
+
|
|
331
379
|
## Development
|
|
332
380
|
|
|
333
381
|
```bash
|
|
@@ -338,4 +386,8 @@ bundle exec rubocop
|
|
|
338
386
|
|
|
339
387
|
## License
|
|
340
388
|
|
|
341
|
-
|
|
389
|
+
© 2026 Konstantin Gredeskoul.
|
|
390
|
+
|
|
391
|
+
Distributed under the MIT License.
|
|
392
|
+
|
|
393
|
+
See [LICENSE.txt](LICENSE.txt) for details.
|
data/lib/inquirex/llm/adapter.rb
CHANGED
|
@@ -28,7 +28,7 @@ module Inquirex
|
|
|
28
28
|
#
|
|
29
29
|
# @param node [LLM::Node] the LLM step to process
|
|
30
30
|
# @param answers [Hash] current collected answers
|
|
31
|
-
# @return [Hash, String] structured output (for
|
|
31
|
+
# @return [Hash, String] structured output (for extract) or text (when no schema)
|
|
32
32
|
# @raise [Errors::AdapterError] if the LLM call fails
|
|
33
33
|
# @raise [Errors::SchemaViolationError] if output doesn't match schema
|
|
34
34
|
def call(node, answers)
|
|
@@ -64,6 +64,45 @@ module Inquirex
|
|
|
64
64
|
raise Errors::SchemaViolationError,
|
|
65
65
|
"LLM output for #{node.id.inspect} missing fields: #{missing.join(", ")}"
|
|
66
66
|
end
|
|
67
|
+
|
|
68
|
+
protected
|
|
69
|
+
|
|
70
|
+
# The schema as a JSON contract for the system prompt: enum-constrained
|
|
71
|
+
# fields render as { "type": ..., "values": [...] } so the model knows
|
|
72
|
+
# the exhaustive list of allowed answers.
|
|
73
|
+
#
|
|
74
|
+
# @param schema [Schema]
|
|
75
|
+
# @return [String] pretty-printed JSON
|
|
76
|
+
def schema_contract_json(schema)
|
|
77
|
+
JSON.pretty_generate(schema.to_h)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Prompt instruction spelling out how value-constrained fields must be
|
|
81
|
+
# answered. Empty string when the schema has no constrained fields.
|
|
82
|
+
#
|
|
83
|
+
# @param schema [Schema]
|
|
84
|
+
# @return [String]
|
|
85
|
+
def values_instruction(schema)
|
|
86
|
+
constrained = schema.field_names.select { |name| schema.values_for(name) }
|
|
87
|
+
return "" if constrained.empty?
|
|
88
|
+
|
|
89
|
+
"\nFor fields that declare \"values\", you MUST answer using ONLY values from that list — " \
|
|
90
|
+
"return a single value for \"enum\" fields and an array of selected values for " \
|
|
91
|
+
"\"multi_enum\" fields. Never invent a value outside the list."
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# One human-readable line per schema field, used in user prompts:
|
|
95
|
+
# income_types (multi_enum: W2 | business | crypto)
|
|
96
|
+
# dependents (integer)
|
|
97
|
+
#
|
|
98
|
+
# @param schema [Schema]
|
|
99
|
+
# @return [Array<String>]
|
|
100
|
+
def field_descriptions(schema)
|
|
101
|
+
schema.fields.map do |field, type|
|
|
102
|
+
values = schema.values_for(field)
|
|
103
|
+
values ? " #{field} (#{type}: #{values.join(" | ")})" : " #{field} (#{type})"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
67
106
|
end
|
|
68
107
|
end
|
|
69
108
|
end
|
|
@@ -86,9 +86,9 @@ module Inquirex
|
|
|
86
86
|
|
|
87
87
|
def schema_instruction(node)
|
|
88
88
|
if node.respond_to?(:schema) && node.schema
|
|
89
|
-
schema_json = node.schema.fields.transform_values(&:to_s)
|
|
90
89
|
"\n\nYou MUST respond with ONLY a valid JSON object matching this schema:\n" \
|
|
91
|
-
"#{
|
|
90
|
+
"#{schema_contract_json(node.schema)}\n" \
|
|
91
|
+
"#{values_instruction(node.schema)}\n\n" \
|
|
92
92
|
"Do not include any text before or after the JSON. No markdown fences. Just the raw JSON object."
|
|
93
93
|
else
|
|
94
94
|
"\n\nRespond with a valid JSON object containing your analysis. " \
|
|
@@ -107,7 +107,7 @@ module Inquirex
|
|
|
107
107
|
|
|
108
108
|
if node.respond_to?(:schema) && node.schema
|
|
109
109
|
parts << "\nExtract these fields from the source data:"
|
|
110
|
-
node.schema
|
|
110
|
+
parts.concat(field_descriptions(node.schema))
|
|
111
111
|
end
|
|
112
112
|
|
|
113
113
|
if node.respond_to?(:from_all) && node.from_all && all_answers.any?
|
|
@@ -4,53 +4,74 @@ module Inquirex
|
|
|
4
4
|
module LLM
|
|
5
5
|
module DSL
|
|
6
6
|
# Mixin that adds LLM verb methods to Inquirex::DSL::FlowBuilder.
|
|
7
|
-
#
|
|
8
|
-
# so that `Inquirex.define` gains clarify
|
|
7
|
+
# Prepended automatically when `require "inquirex-llm"` is called,
|
|
8
|
+
# so that `Inquirex.define` gains `extract` (and its `clarify` alias)
|
|
9
9
|
# without needing a separate entry point.
|
|
10
10
|
#
|
|
11
11
|
# All core verbs (ask, say, header, btw, warning, confirm) remain
|
|
12
|
-
# unchanged — LLM verbs are purely additive.
|
|
12
|
+
# unchanged — LLM verbs are purely additive. The mixin must be
|
|
13
|
+
# prepended (not included) because it overrides #build: LLM steps are
|
|
14
|
+
# built lazily at #build time, once every step in the flow is known,
|
|
15
|
+
# so that schema question references can resolve forward to questions
|
|
16
|
+
# defined after the LLM step.
|
|
13
17
|
module FlowBuilderExtension
|
|
14
18
|
# Defines an LLM extraction step: takes free-text input and produces
|
|
15
19
|
# structured data matching the declared schema.
|
|
16
20
|
#
|
|
17
21
|
# @param id [Symbol] step id
|
|
18
|
-
def
|
|
19
|
-
add_llm_step(id, :
|
|
22
|
+
def extract(id, &)
|
|
23
|
+
add_llm_step(id, :extract, &)
|
|
20
24
|
end
|
|
21
25
|
|
|
22
|
-
|
|
23
|
-
# natural-language text.
|
|
24
|
-
#
|
|
25
|
-
# @param id [Symbol] step id
|
|
26
|
-
def describe(id, &)
|
|
27
|
-
add_llm_step(id, :describe, &)
|
|
28
|
-
end
|
|
26
|
+
alias clarify extract
|
|
29
27
|
|
|
30
|
-
# Defines an LLM
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
# @param id [Symbol] step id
|
|
34
|
-
def
|
|
35
|
-
|
|
36
|
-
end
|
|
28
|
+
# # Defines an LLM description step: takes structured data and produces
|
|
29
|
+
# # natural-language text.
|
|
30
|
+
# #
|
|
31
|
+
# # @param id [Symbol] step id
|
|
32
|
+
# def describe(id, &)
|
|
33
|
+
# add_llm_step(id, :describe, &)
|
|
34
|
+
# end
|
|
37
35
|
|
|
38
|
-
# Defines an LLM
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
|
|
44
|
-
|
|
36
|
+
# # Defines an LLM summarization step: takes all or selected answers and
|
|
37
|
+
# # produces a textual summary.
|
|
38
|
+
# #
|
|
39
|
+
# # @param id [Symbol] step id
|
|
40
|
+
# def summarize(id, &)
|
|
41
|
+
# add_llm_step(id, :summarize, &)
|
|
42
|
+
# end
|
|
43
|
+
|
|
44
|
+
# # Defines an LLM detour step: based on an answer, dynamically generates
|
|
45
|
+
# # follow-up questions. The server adapter handles presenting the generated
|
|
46
|
+
# # questions and collecting responses.
|
|
47
|
+
# #
|
|
48
|
+
# # @param id [Symbol] step id
|
|
49
|
+
# def detour(id, &)
|
|
50
|
+
# add_llm_step(id, :detour, &)
|
|
51
|
+
# end
|
|
52
|
+
|
|
53
|
+
# Builds any deferred LLM steps (now that the full node map exists),
|
|
54
|
+
# then produces the frozen Definition via the core builder.
|
|
55
|
+
def build
|
|
56
|
+
resolve_llm_steps!
|
|
57
|
+
super
|
|
45
58
|
end
|
|
46
59
|
|
|
47
60
|
private
|
|
48
61
|
|
|
49
|
-
#
|
|
62
|
+
# Evaluates the step block immediately (same as core FlowBuilder#add_step)
|
|
63
|
+
# but parks the builder in the node map instead of building the node.
|
|
64
|
+
# The builder placeholder holds this step's position; #build replaces it.
|
|
50
65
|
def add_llm_step(id, verb, &block)
|
|
51
66
|
builder = LlmStepBuilder.new(verb)
|
|
52
67
|
builder.instance_eval(&block) if block
|
|
53
|
-
@nodes[id.to_sym] = builder
|
|
68
|
+
@nodes[id.to_sym] = builder
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def resolve_llm_steps!
|
|
72
|
+
@nodes.each do |id, entry|
|
|
73
|
+
@nodes[id] = entry.build(id, nodes: @nodes) if entry.is_a?(LlmStepBuilder)
|
|
74
|
+
end
|
|
54
75
|
end
|
|
55
76
|
end
|
|
56
77
|
end
|