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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d88efd2e36800946885576819285836d651aec1b90529504f25399a7856b447d
4
- data.tar.gz: 6be4cf2107958061b45840a87e24c954fa9951458b4cbf868a7bfc8368dd3007
3
+ metadata.gz: 00b1d649377bb6ff1281a8fd3af730995b86531e44c930cb3c9a267b73e5efe0
4
+ data.tar.gz: '0478c2970b4d501ee3b9b2ed4dcdd2d620fbc99c522ac863e3ac772a4f75dce6'
5
5
  SHA512:
6
- metadata.gz: aeb78792e0f106dd619043daf3b3eb21a7e209ad983bf8f5349eefeb74e071b5ca25b11bd445acbb77b0e1926b170347d1ca38a6c75c7d147cbf6d46ffed79dd
7
- data.tar.gz: e4f00de410d54bb8c4a595816e681a0ca4c1d26a2bdc01583ff636a3b2dddaedef6b499af090e6b18f1f62ff64446122ff2529116f9f19512ef349ff615476c9
6
+ metadata.gz: a89a5e3ec65fab4be526a2d8bc730c8bb89bfe9b7945ef900fb987546765c29600219e1959dfb9011a43916658feb56f1f5b70625d3645a26aa75bbbe290ebdb
7
+ data.tar.gz: b9c05000c0b31721f3c00d425b0e525ef309540335d08c10c5c69ba2a6089fab2bc2d10ce53da141f62f1735a631c993b798ed8a7353c50cfb225b4635d59697
data/README.md CHANGED
@@ -1,42 +1,31 @@
1
- [![Ruby](https://github.com/inquirex/inquirex-llm/actions/workflows/main.yml/badge.svg)](https://github.com/inquirex/inquirex-llm/actions/workflows/main.yml)  ![Coverage](docs/badges/coverage_badge.svg)
1
+ [![Gem Version](https://badge.fury.io/rb/inquirex-llm.svg)](https://badge.fury.io/rb/inquirex-llm) [![Ruby](https://github.com/inquirex/inquirex-llm/actions/workflows/main.yml/badge.svg)](https://github.com/inquirex/inquirex-llm/actions/workflows/main.yml) ![Coverage](docs/badges/coverage_badge.svg)
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 four server-side verbs -- `clarify`, `describe`, `summarize`, and `detour` -- that bridge free-text answers and structured data via LLM processing. Ships with a pluggable adapter interface and a `NullAdapter` for testing.
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 `i`
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 on 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 form the presentation](https://reinvent.one/images/talks/pdfs/2026.inquirex.pdf).
23
-
24
- ## Status
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. No separate entry point needed.
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
- clarify :extracted do
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`, `prompt`, and `schema`.
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
- clarify :business_extracted do
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
- ### `describe`
81
+ ## Schema: Question References (preferred)
99
82
 
100
- Generate natural-language text from structured data. Requires `from` and `prompt`. No schema needed.
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
- describe :business_narrative do
104
- from :business_extracted
105
- prompt "Write a brief narrative of this business for the intake report."
106
- transition to: :next_step
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
- ### `summarize`
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
- Produce a summary of all or selected answers. Use `from_all` to pass everything, or `from` to select specific steps.
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
- summarize :intake_summary do
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
- ### `detour`
124
+ ### `prompt :auto`
123
125
 
124
- 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`.
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
- detour :followup do
131
+ extract :extracted do
128
132
  from :description
129
- prompt "Generate 2-3 follow-up questions to clarify the tax situation."
130
- schema questions: :array, answers: :hash
131
- transition to: :next_step
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 | Purpose | Required |
138
- |--------|---------|----------|
139
- | `prompt "..."` | LLM prompt template | Always |
140
- | `schema key: :type, ...` | Expected output structure | `clarify`, `detour` |
141
- | `from :step_id` | Source step(s) whose answers feed the LLM | `clarify`, `describe`, `detour` |
142
- | `from_all` | Pass all collected answers to the LLM | Alternative to `from` |
143
- | `model :claude_sonnet` | Optional model hint for the adapter | No |
144
- | `temperature 0.3` | Optional sampling temperature | No |
145
- | `max_tokens 1024` | Optional max output tokens | No |
146
- | `fallback { \|answers\| ... }` | Server-side fallback (stripped from JSON) | No |
147
- | `transition to: :step` | Conditional transition (same as core) | No |
148
- | `skip_if rule` | Skip step when condition is true | No |
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 | Provider | API | Auth | Key env var |
180
- |------------------------------------|-----------|---------------------------------------|-----------------------------|-----------------------|
181
- | `Inquirex::LLM::NullAdapter` | — | none (placeholders) | none | — |
182
- | `Inquirex::LLM::AnthropicAdapter` | Anthropic | `/v1/messages` | `x-api-key` header | `ANTHROPIC_API_KEY` |
183
- | `Inquirex::LLM::OpenAIAdapter` | OpenAI | `/v1/chat/completions` (JSON mode) | `Authorization: Bearer …` | `OPENAI_API_KEY` |
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
- Both real adapters use `net/http` (stdlib, no extra dependency), inject the
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
- clarify :extracted do
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 clarify schema
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": "clarify",
288
+ "verb": "extract",
289
289
  "requires_server": true,
290
- "transitions": [{ "to": "summary", "requires_server": true }],
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
- Fallback procs are stripped from JSON (server-side only).
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
- MIT. See [LICENSE.txt](LICENSE.txt).
389
+ © 2026 Konstantin Gredeskoul.
390
+
391
+ Distributed under the MIT License.
392
+
393
+ See [LICENSE.txt](LICENSE.txt) for details.
@@ -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 clarify/detour) or text (for describe/summarize)
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
- "#{JSON.pretty_generate(schema_json)}\n\n" \
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.fields.each { |field, type| parts << " #{field} (#{type})" }
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
- # Included automatically when `require "inquirex-llm"` is called,
8
- # so that `Inquirex.define` gains clarify/describe/summarize/detour
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 clarify(id, &)
19
- add_llm_step(id, :clarify, &)
22
+ def extract(id, &)
23
+ add_llm_step(id, :extract, &)
20
24
  end
21
25
 
22
- # Defines an LLM description step: takes structured data and produces
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 summarization step: takes all or selected answers and
31
- # produces a textual summary.
32
- #
33
- # @param id [Symbol] step id
34
- def summarize(id, &)
35
- add_llm_step(id, :summarize, &)
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 detour step: based on an answer, dynamically generates
39
- # follow-up questions. The server adapter handles presenting the generated
40
- # questions and collecting responses.
41
- #
42
- # @param id [Symbol] step id
43
- def detour(id, &)
44
- add_llm_step(id, :detour, &)
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
- # Uses the standard Ruby builder pattern (same as core FlowBuilder#add_step).
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.build(id)
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