inquirex-llm
LLM integration verbs for the Inquirex questionnaire engine.
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.)
inquirex is a pure Ruby, declarative, rules-driven questionnaire engine for building conditional intake forms, qualification wizards, and branching surveys.
[!IMPORTANT]
Note that
inquirex-llmis part of an entire ecosystem that contains:
inquirexinquirex-llminquirex-ttyinquirex-js(npmjsmodule@kigster/inquirex-js)For a presentation about these gems and what they do please watch the RubySF presentation and you can also view the slides form the presentation.
Finally, the SaaS application qualified.at allows users to leverage the ecosystem by creating their own custom lead intake flows and integrating them on their own sites.
Usage
require "inquirex-llm" injects the LLM verbs into the core Inquirex.define DSL.
No separate entry point needed.
require "inquirex"
require "inquirex-llm"
definition = Inquirex.define id: "tax-intake-2026", version: "1.0.0" do
start :description
ask :description do
type :text
question "Describe your business in a few sentences."
transition to: :extracted
end
extract :extracted do
from :description
prompt "Extract structured business information from the description."
schema industry: :string,
entity_type: :string,
employee_count: :integer,
estimated_revenue: :currency
model :claude_sonnet
temperature 0.2
transition to: :done
end
say :done do
text "Thank you! We'll be in touch."
end
end
All core verbs (ask, say, header, btw, warning, confirm) and widget hints work alongside LLM verbs in the same Inquirex.define block.
Currently Supported LLM Verbs
extract (alias: clarify)
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.
extract :business_extracted do
from :business_description
prompt "Extract structured business information."
schema industry: :string, employee_count: :integer, revenue: :currency
model :claude_sonnet
temperature 0.2
max_tokens 1024
transition to: :next_step
end
DSL Methods (inside LLM verb blocks)
| Method | Purpose | Required | ||
|---|---|---|---|---|
prompt "..." |
LLM prompt template | Always | ||
schema key: :type, ... |
Expected output structure | extract |
||
from :step_id |
Source step(s) whose answers feed the LLM | extract (or use from_all) |
||
from_all |
Pass all collected answers to the LLM | Alternative to from |
||
model :claude_sonnet |
Optional model hint for the adapter | No | ||
temperature 0.3 |
Optional sampling temperature | No | ||
max_tokens 1024 |
Optional max output tokens | No | ||
| `fallback { \ | answers\ | ... }` | Server-side fallback (stripped from JSON) | No |
transition to: :step |
Conditional transition (same as core) | No | ||
skip_if rule |
Skip step when condition is true | No |
Engine Integration
The engine treats LLM steps as collecting steps. The server adapter processes the LLM call and feeds the result back:
engine = Inquirex::Engine.new(definition)
engine.answer("I run an LLC with 15 employees, ~$2M revenue.")
# engine.current_step_id => :extracted
# Server-side: adapter calls the LLM
adapter = MyLlmAdapter.new
result = adapter.call(engine.current_step, engine.answers)
# => { industry: "Technology", employee_count: 15, revenue: 2_000_000.0 }
engine.answer(result)
# engine.current_step_id => :summary
For testing, use NullAdapter which returns schema-conformant placeholder values without any API calls:
adapter = Inquirex::LLM::NullAdapter.new
result = adapter.call(engine.current_step)
# => { industry: "", employee_count: 0, revenue: 0.0 }
Built-in Adapters
| Class | Provider | API | Auth | Key env var |
|---|---|---|---|---|
Inquirex::LLM::NullAdapter |
— | none (placeholders) | none | — |
Inquirex::LLM::AnthropicAdapter |
Anthropic | /v1/messages |
x-api-key header |
ANTHROPIC_API_KEY |
Inquirex::LLM::OpenAIAdapter |
OpenAI | /v1/chat/completions (JSON mode) |
Authorization: Bearer … |
OPENAI_API_KEY |
Inquirex::LLM::LittleLLMAdapter (TBD) |
Any | OpenAI Compatible API | OpenAI Compatible Auth | Provider Specific |
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.
AnthropicAdapter
adapter = Inquirex::LLM::AnthropicAdapter.new(
api_key: ENV["ANTHROPIC_API_KEY"],
model: "claude-sonnet-4-20250514" # or pass the short symbol in the DSL
)
Recognized model :symbol values in the DSL: :claude_sonnet, :claude_haiku, :claude_opus (mapped to the current concrete model ids).
OpenAIAdapter
adapter = Inquirex::LLM::OpenAIAdapter.new(
api_key: ENV["OPENAI_API_KEY"],
model: "gpt-4o-mini"
)
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.
LLM-assisted Pre-fill Pattern
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:
definition = Inquirex.define id: "tax-intake" do
start :describe
ask :describe do
type :text
question "Describe your 2025 tax situation."
transition to: :extracted
end
extract :extracted do
from :describe
prompt "Extract: filing_status, dependents, income_types, state_filing."
schema filing_status: :string,
dependents: :integer,
income_types: :multi_enum,
state_filing: :string
model :claude_sonnet
transition to: :filing_status
end
ask :filing_status do
type :enum
question "Filing status?"
%w[single married_filing_jointly head_of_household]
skip_if not_empty(:filing_status) # ← the whole trick
transition to: :dependents
end
ask :dependents do
type :integer
question "How many dependents?"
skip_if not_empty(:dependents)
transition to: :income_types
end
# …and so on for every field in the extract schema
end
engine = Inquirex::Engine.new(definition)
adapter = Inquirex::LLM::OpenAIAdapter.new # or AnthropicAdapter
engine.answer("I'm MFJ with two kids in California, W-2 plus some crypto.")
result = adapter.call(engine.current_step, engine.answers)
engine.answer(result) # stored under :extracted
engine.prefill!(result) # splats into top-level answers
# Every downstream step whose skip_if rule now evaluates true gets
# auto-skipped by the engine. engine.current_step_id jumps straight to
# whichever field the LLM couldn't fill in.
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 for a complete runnable flow, or the repo-level demo_llm_intake.rb for a scripted end-to-end walkthrough.
JSON Serialization
LLM steps serialize with "requires_server": true so the JS widget knows to round-trip to the server. LLM metadata lives under an "llm" key:
{
"verb": "extract",
"requires_server": true,
"transitions": [{ "to": "next_step", "requires_server": true }],
"llm": {
"prompt": "Extract structured business information.",
"schema": {
"industry": "string",
"employee_count": "integer",
"revenue": "currency"
},
"from_steps": ["business_description"],
"model": "claude_sonnet",
"temperature": 0.2,
"max_tokens": 1024
}
}
Fallback procs are stripped from JSON (server-side only).
Custom Adapter
Subclass Inquirex::LLM::Adapter and implement #call(node, answers):
class MyLlmAdapter < Inquirex::LLM::Adapter
def call(node, answers)
source = source_answers(node, answers)
response = my_llm_client.complete(
node.prompt,
context: source,
model: node.model,
temperature: node.temperature
)
result = parse_response(response)
validate_output!(node, result)
result
end
end
The base class provides #source_answers (gathers relevant answers) and #validate_output! (checks schema conformance).
Future Possible LLM Verbs
describe
Generate natural-language text from structured data. Requires from and prompt. No schema needed.
describe :business_narrative do
from :business_extracted
prompt "Write a brief narrative of this business for the intake report."
transition to: :next_step
end
summarize
Produce a summary of all or selected answers. Use from_all to pass everything, or from to select specific steps.
summarize :intake_summary do
from_all
prompt "Summarize this client's tax situation."
transition to: :review
end
detour (parked)
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.
detour :followup do
from :description
prompt "Generate 2-3 follow-up questions to clarify the tax situation."
schema questions: :array, answers: :hash
transition to: :next_step
end
Development
bundle install
bundle exec rspec
bundle exec rubocop
License
© 2026 Konstantin Gredeskoul.
Distributed under the MIT License.
See LICENSE.txt for details.