inquirex-llm 0.1.0 → 0.4.1

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: 10d488773dcdef0cbf1f5fe93cdb916b7bf2bdf585e45d283b98fcda77dcd68d
4
- data.tar.gz: 8c8128942112c74522f8f8c4e3be86904eeb5be510400f9c104f908ec24c7d5e
3
+ metadata.gz: d88efd2e36800946885576819285836d651aec1b90529504f25399a7856b447d
4
+ data.tar.gz: 6be4cf2107958061b45840a87e24c954fa9951458b4cbf868a7bfc8368dd3007
5
5
  SHA512:
6
- metadata.gz: a2ec2170b2fd8dd56bbfaa05ad3dd9507c6880ddce90993caa2af9519ccc002a869d87e0c784f855272480ffb1500f545152daeff0d5f29dcb9d9b68ffdb3849
7
- data.tar.gz: 4f53ae8500c6cb8f11d89952f4b101a6fd919e3902e3d8bfed2b06fb0cb8b899b1fe93dfe309fddfb8e4c523b2b10d60d96551b9a2084efef09f118edaef197b
6
+ metadata.gz: aeb78792e0f106dd619043daf3b3eb21a7e209ad983bf8f5349eefeb74e071b5ca25b11bd445acbb77b0e1926b170347d1ca38a6c75c7d147cbf6d46ffed79dd
7
+ data.tar.gz: e4f00de410d54bb8c4a595816e681a0ca4c1d26a2bdc01583ff636a3b2dddaedef6b499af090e6b18f1f62ff64446122ff2529116f9f19512ef349ff615476c9
data/.rubocop_todo.yml ADDED
@@ -0,0 +1,28 @@
1
+ # This configuration was generated by
2
+ # `rubocop --auto-gen-config`
3
+ # on 2026-04-14 23:51:42 UTC using RuboCop version 1.86.1.
4
+ # The point is for the user to remove these configuration records
5
+ # one by one as the offenses are removed from the code base.
6
+ # Note that changes in the inspected code, or installation of new
7
+ # versions of RuboCop, may require this file to be generated again.
8
+
9
+ # Offense count: 1
10
+ # Configuration parameters: EnforcedStyle, CheckMethodNames, CheckSymbols, AllowedIdentifiers, AllowedPatterns.
11
+ # SupportedStyles: snake_case, normalcase, non_integer
12
+ # AllowedIdentifiers: TLS1_1, TLS1_2, capture3, iso8601, rfc1123_date, rfc822, rfc2822, rfc3339, x86_64
13
+ Naming/VariableNumber:
14
+ Exclude:
15
+ - 'lib/inquirex/llm/openai_adapter.rb'
16
+
17
+ # Offense count: 2
18
+ # Configuration parameters: AllowSubject.
19
+ RSpec/MultipleMemoizedHelpers:
20
+ Max: 6
21
+
22
+ # Offense count: 1
23
+ # Configuration parameters: CustomTransform, IgnoreMethods, IgnoreMetadata, InflectorPath, EnforcedInflector.
24
+ # SupportedInflectors: default, active_support
25
+ RSpec/SpecFilePathFormat:
26
+ Exclude:
27
+ - '**/spec/routing/**/*'
28
+ - 'spec/inquirex/llm/openai_adapter_spec.rb'
data/README.md CHANGED
@@ -6,6 +6,21 @@ LLM integration verbs for the [Inquirex](https://github.com/inquirex/inquirex) q
6
6
 
7
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.
8
8
 
9
+ `inquirex` is a pure Ruby, declarative, rules-driven questionnaire engine for building conditional intake forms, qualification wizards, and branching surveys.
10
+
11
+ > [!IMPORTANT]
12
+ >
13
+ > Note that `i`
14
+ >
15
+ > `nquirex` is the base gem of the ecosystem that contains:
16
+ >
17
+ > - [`inquirex`](https://github.com/inquirex/inquirex)
18
+ > - [`inquirex-llm`](https://github.com/inquirex/inquirex-llm)
19
+ > - [`inquirex-tty`](https://github.com/inquirex/inquirex-)
20
+ > - [`inquirex-js`](https://github.com/inquirex/inquirex-js)
21
+ >
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
+
9
24
  ## Status
10
25
 
11
26
  - Version: `0.1.0`
@@ -159,6 +174,111 @@ result = adapter.call(engine.current_step)
159
174
  # => { industry: "", employee_count: 0, revenue: 0.0 }
160
175
  ```
161
176
 
177
+ ## Built-in Adapters
178
+
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` |
184
+
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.
189
+
190
+ ### AnthropicAdapter
191
+
192
+ ```ruby
193
+ adapter = Inquirex::LLM::AnthropicAdapter.new(
194
+ api_key: ENV["ANTHROPIC_API_KEY"],
195
+ model: "claude-sonnet-4-20250514" # or pass the short symbol in the DSL
196
+ )
197
+ ```
198
+
199
+ Recognized `model :symbol` values in the DSL: `:claude_sonnet`,
200
+ `:claude_haiku`, `:claude_opus` (mapped to the current concrete model ids).
201
+
202
+ ### OpenAIAdapter
203
+
204
+ ```ruby
205
+ adapter = Inquirex::LLM::OpenAIAdapter.new(
206
+ api_key: ENV["OPENAI_API_KEY"],
207
+ model: "gpt-4o-mini"
208
+ )
209
+ ```
210
+
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.
217
+
218
+ ## LLM-assisted Pre-fill Pattern
219
+
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:
223
+
224
+ ```ruby
225
+ definition = Inquirex.define id: "tax-intake" do
226
+ start :describe
227
+
228
+ ask :describe do
229
+ type :text
230
+ question "Describe your 2025 tax situation."
231
+ transition to: :extracted
232
+ end
233
+
234
+ clarify :extracted do
235
+ from :describe
236
+ prompt "Extract: filing_status, dependents, income_types, state_filing."
237
+ schema filing_status: :string,
238
+ dependents: :integer,
239
+ income_types: :multi_enum,
240
+ state_filing: :string
241
+ model :claude_sonnet
242
+ transition to: :filing_status
243
+ end
244
+
245
+ ask :filing_status do
246
+ type :enum
247
+ question "Filing status?"
248
+ options %w[single married_filing_jointly head_of_household]
249
+ skip_if not_empty(:filing_status) # ← the whole trick
250
+ transition to: :dependents
251
+ end
252
+
253
+ ask :dependents do
254
+ type :integer
255
+ question "How many dependents?"
256
+ skip_if not_empty(:dependents)
257
+ transition to: :income_types
258
+ end
259
+ # …and so on for every field in the clarify schema
260
+ end
261
+
262
+ engine = Inquirex::Engine.new(definition)
263
+ adapter = Inquirex::LLM::OpenAIAdapter.new # or AnthropicAdapter
264
+
265
+ engine.answer("I'm MFJ with two kids in California, W-2 plus some crypto.")
266
+ result = adapter.call(engine.current_step, engine.answers)
267
+ engine.answer(result) # stored under :extracted
268
+ engine.prefill!(result) # splats into top-level answers
269
+
270
+ # Every downstream step whose skip_if rule now evaluates true gets
271
+ # auto-skipped by the engine. engine.current_step_id jumps straight to
272
+ # whichever field the LLM couldn't fill in.
273
+ ```
274
+
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.
281
+
162
282
  ## JSON Serialization
163
283
 
164
284
  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:
@@ -15,7 +15,7 @@
15
15
  <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
16
16
  <text x="31.5" y="15" fill="#010101" fill-opacity=".3">coverage</text>
17
17
  <text x="31.5" y="14">coverage</text>
18
- <text x="80" y="15" fill="#010101" fill-opacity=".3">98%</text>
19
- <text x="80" y="14">98%</text>
18
+ <text x="80" y="15" fill="#010101" fill-opacity=".3">99%</text>
19
+ <text x="80" y="14">99%</text>
20
20
  </g>
21
21
  </svg>
data/justfile CHANGED
@@ -1,38 +1,69 @@
1
- set shell := ["bash", "-lc"]
1
+ # Tell 'just' to run bash, source our setup script, then execute the recipe
2
+ set shell := ["bash", "-c"]
2
3
 
3
- rbenv := 'eval "$(rbenv init -)"'
4
+ version := `grep VERSION lib/inquirex/llm/version.rb | awk '{print $3}' | tr -d '"' | tr -d '\n'`
5
+ rbenv := 'eval "$(rbenv init bash)"; bundle exec '
6
+ repo := 'git@github.com:inquirex/inquirex.git'
4
7
 
5
8
  [no-exit-message]
6
9
  recipes:
7
- @just --choose
10
+ just --choose
8
11
 
9
12
  # Sync all dependencies
10
13
  install:
11
- {{rbenv}} && bin/setup
14
+ bin/setup
12
15
 
13
- # Lint and reformat files
14
- lint-fix *args:
15
- {{rbenv}} && bundle exec rubocop -a
16
-
17
- alias format := lint-fix
16
+ build: install
18
17
 
19
18
  # Lint and reformat files
20
19
  lint:
21
- {{rbenv}} && bundle exec rubocop
20
+ {{ rbenv }} rubocop
21
+
22
+ # Lint and reformat files (-a) — pass -A as an argument
23
+ format *args:
24
+ {{ rbenv }} rubocop -a {{ args }}
22
25
 
23
26
  # Run all the tests
24
27
  test *args:
25
- {{rbenv}} && ENVIRONMENT=test bundle exec rspec {{args}}
28
+ export ENVIRONMENT=test; {{ rbenv }} rspec {{args}}
26
29
 
27
30
  # Run tests with coverage
28
31
  test-coverage *args:
29
- ENVIRONMENT=test COVERAGE=true bundle exec rspec
32
+ export ENVIRONMENT=test; export COVERAGE=true; {{ rbenv }} rspec {{ args }}
33
+
34
+ check-all: install lint test-coverage
30
35
 
31
36
  clean:
32
37
  #!/usr/bin/env bash
33
- find . -name .DS_Store -delete -print || true
34
- rm -rf tmp/*
38
+ @find . -name .DS_Store -delete -print || true
39
+ @rm -rf tmp/*
35
40
 
36
41
  # Run all lefthook pre-commit hooks
37
- ci:
38
- {{rbenv}} && lefthook run pre-commit --all-files
42
+ lefthook:
43
+ {{ rbenv }} lefthook run pre-commit --all-files
44
+
45
+ # Print current gem version
46
+ version:
47
+ @echo "{{ version }}"
48
+
49
+ # Clobber
50
+ clobber:
51
+ {{ rbenv }} rake clobber
52
+
53
+ # Generate documentation
54
+ doc:
55
+ #!/usr/bin/env bash
56
+ {{ rbenv }} rake doc
57
+
58
+ # Create
59
+ publish: build
60
+ {{ rbenv }} rake release[remote]
61
+
62
+
63
+ # Tag v{{ version }}, publish the GH release, & refresh the Homebrew tap.
64
+ release:
65
+ git fetch --tags
66
+ git tag -f "v{{ version }}"
67
+ git push -f --tags
68
+ gh release delete -y "v{{ version }}" --repo {{ repo }} 2>/dev/null || true
69
+ gh release create "v{{ version }}" --generate-notes --repo {{ repo }}
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "inquirex/llm/adapter"
7
+
8
+ module Inquirex
9
+ module LLM
10
+ # Real Anthropic Claude adapter for inquirex-llm.
11
+ #
12
+ # Usage:
13
+ # adapter = Inquirex::LLM::AnthropicAdapter.new(
14
+ # api_key: ENV["ANTHROPIC_API_KEY"],
15
+ # model: "claude-sonnet-4-20250514"
16
+ # )
17
+ # result = adapter.call(engine.current_step, engine.answers)
18
+ #
19
+ # The adapter:
20
+ # 1. Gathers source answers from the step's `from` / `from_all` declaration
21
+ # 2. Builds a prompt that includes the schema as a JSON contract
22
+ # 3. Calls the Anthropic Messages API
23
+ # 4. Parses the JSON response
24
+ # 5. Validates output against the declared schema
25
+ # 6. Returns the structured hash
26
+ class AnthropicAdapter < Adapter
27
+ API_URL = "https://api.anthropic.com/v1/messages"
28
+ API_VERSION = "2023-06-01"
29
+ DEFAULT_MODEL = "claude-sonnet-4-20250514"
30
+ DEFAULT_MAX_TOKENS = 2048
31
+
32
+ # Maps Inquirex short model symbols to concrete Anthropic model ids.
33
+ MODEL_MAP = {
34
+ claude_sonnet: "claude-sonnet-4-20250514",
35
+ claude_haiku: "claude-haiku-4-5-20251001",
36
+ claude_opus: "claude-opus-4-20250514"
37
+ }.freeze
38
+
39
+ # @param api_key [String, nil] defaults to ENV["ANTHROPIC_API_KEY"]
40
+ # @param model [String, nil] default model id when a node does not specify one
41
+ def initialize(api_key: nil, model: nil)
42
+ super()
43
+ @api_key = api_key || ENV.fetch("ANTHROPIC_API_KEY") {
44
+ raise ArgumentError, "ANTHROPIC_API_KEY is required (pass api_key: or set the env var)"
45
+ }
46
+ @default_model = model || DEFAULT_MODEL
47
+ end
48
+
49
+ # @param node [Inquirex::LLM::Node] the current LLM step
50
+ # @param answers [Hash] all collected answers so far
51
+ # @return [Hash] structured data matching the node's schema
52
+ # @raise [Errors::AdapterError] on API / parse failures
53
+ # @raise [Errors::SchemaViolationError] when the LLM output misses schema fields
54
+ def call(node, answers = {})
55
+ source = source_answers(node, answers)
56
+ model = resolve_model(node)
57
+ temperature = node.respond_to?(:temperature) ? (node.temperature || 0.2) : 0.2
58
+ max_tokens = node.respond_to?(:max_tokens) ? (node.max_tokens || DEFAULT_MAX_TOKENS) : DEFAULT_MAX_TOKENS
59
+
60
+ response = call_api(
61
+ model: model,
62
+ system: build_system_prompt(node),
63
+ user: build_user_prompt(node, source, answers),
64
+ temperature: temperature,
65
+ max_tokens: max_tokens
66
+ )
67
+
68
+ result = parse_response(response)
69
+ validate_output!(node, result)
70
+ result
71
+ end
72
+
73
+ private
74
+
75
+ def resolve_model(node)
76
+ return @default_model unless node.respond_to?(:model) && node.model
77
+
78
+ MODEL_MAP[node.model.to_sym] || node.model.to_s
79
+ end
80
+
81
+ def build_system_prompt(node)
82
+ "You are a data extraction assistant for a questionnaire engine. " \
83
+ "Your job is to analyze user input and extract structured data." +
84
+ schema_instruction(node)
85
+ end
86
+
87
+ def schema_instruction(node)
88
+ if node.respond_to?(:schema) && node.schema
89
+ schema_json = node.schema.fields.transform_values(&:to_s)
90
+ "\n\nYou MUST respond with ONLY a valid JSON object matching this schema:\n" \
91
+ "#{JSON.pretty_generate(schema_json)}\n\n" \
92
+ "Do not include any text before or after the JSON. No markdown fences. Just the raw JSON object."
93
+ else
94
+ "\n\nRespond with a valid JSON object containing your analysis. " \
95
+ "No markdown fences. Just the raw JSON object."
96
+ end
97
+ end
98
+
99
+ def build_user_prompt(node, source, all_answers)
100
+ parts = []
101
+ parts << "Task: #{node.prompt}" if node.respond_to?(:prompt) && node.prompt
102
+
103
+ if source.is_a?(Hash) && source.any?
104
+ parts << "\nSource data from previous answers:"
105
+ source.each { |key, value| parts << " #{key}: #{value.inspect}" }
106
+ end
107
+
108
+ if node.respond_to?(:schema) && node.schema
109
+ parts << "\nExtract these fields from the source data:"
110
+ node.schema.fields.each { |field, type| parts << " #{field} (#{type})" }
111
+ end
112
+
113
+ if node.respond_to?(:from_all) && node.from_all && all_answers.any?
114
+ parts << "\nAll collected answers:"
115
+ all_answers.each { |key, value| parts << " #{key}: #{value.inspect}" }
116
+ end
117
+
118
+ parts.join("\n")
119
+ end
120
+
121
+ def call_api(model:, system:, user:, temperature:, max_tokens:)
122
+ uri = URI(API_URL)
123
+ http = Net::HTTP.new(uri.host, uri.port)
124
+ http.use_ssl = true
125
+ http.read_timeout = 30
126
+ http.open_timeout = 10
127
+
128
+ request = Net::HTTP::Post.new(uri)
129
+ request["Content-Type"] = "application/json"
130
+ request["x-api-key"] = @api_key
131
+ request["anthropic-version"] = API_VERSION
132
+ request.body = JSON.generate(
133
+ model: model,
134
+ max_tokens: max_tokens,
135
+ temperature: temperature,
136
+ system: system,
137
+ messages: [{ role: "user", content: user }]
138
+ )
139
+
140
+ warn "[inquirex-llm] Calling #{model}..." if ENV["INQUIREX_DEBUG"]
141
+
142
+ response = http.request(request)
143
+ unless response.is_a?(Net::HTTPSuccess)
144
+ raise Errors::AdapterError, "Anthropic API error #{response.code}: #{response.body}"
145
+ end
146
+
147
+ JSON.parse(response.body)
148
+ end
149
+
150
+ def parse_response(api_response)
151
+ content = api_response["content"]
152
+ text_block = content.is_a?(Array) ? content.find { |c| c["type"] == "text" } : nil
153
+ raise Errors::AdapterError, "No text content in Anthropic response" unless text_block
154
+
155
+ raw_text = text_block["text"].to_s.strip
156
+ raw_text = raw_text.gsub(/\A```(?:json)?\s*\n?/, "").gsub(/\n?```\s*\z/, "").strip
157
+
158
+ parsed = JSON.parse(raw_text, symbolize_names: true)
159
+ raise Errors::AdapterError, "Expected JSON object from LLM, got #{parsed.class}" unless parsed.is_a?(Hash)
160
+
161
+ parsed
162
+ rescue JSON::ParserError => e
163
+ raise Errors::AdapterError,
164
+ "Failed to parse LLM response as JSON: #{e.message}\nRaw: #{raw_text.inspect}"
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "inquirex/llm/adapter"
7
+
8
+ module Inquirex
9
+ module LLM
10
+ # OpenAI Chat Completions adapter for inquirex-llm.
11
+ #
12
+ # Uses the Chat Completions API with response_format: { type: "json_object" }
13
+ # so the model is constrained to return a valid JSON object — more reliable
14
+ # than prompt-only "please return JSON" approaches for structured extraction.
15
+ #
16
+ # Usage:
17
+ # adapter = Inquirex::LLM::OpenAIAdapter.new(
18
+ # api_key: ENV["OPENAI_API_KEY"],
19
+ # model: "gpt-4o-mini"
20
+ # )
21
+ # result = adapter.call(engine.current_step, engine.answers)
22
+ class OpenAIAdapter < Adapter
23
+ API_URL = "https://api.openai.com/v1/chat/completions"
24
+ DEFAULT_MODEL = "gpt-4o-mini"
25
+ DEFAULT_MAX_TOKENS = 2048
26
+
27
+ # Maps Inquirex DSL model symbols to concrete OpenAI model ids. Accepts
28
+ # Claude symbols too — we substitute sensible OpenAI equivalents so flow
29
+ # definitions written against Anthropic still run against this adapter.
30
+ MODEL_MAP = {
31
+ gpt_4o: "gpt-4o",
32
+ gpt_4o_mini: "gpt-4o-mini",
33
+ gpt_4_1: "gpt-4.1",
34
+ gpt_4_1_mini: "gpt-4.1-mini",
35
+ claude_sonnet: "gpt-4o",
36
+ claude_haiku: "gpt-4o-mini",
37
+ claude_opus: "gpt-4o"
38
+ }.freeze
39
+
40
+ # @param api_key [String, nil] defaults to ENV["OPENAI_API_KEY"]
41
+ # @param model [String, nil] default model id when a node does not specify one
42
+ def initialize(api_key: nil, model: nil)
43
+ super()
44
+ @api_key = api_key || ENV.fetch("OPENAI_API_KEY") {
45
+ raise ArgumentError, "OPENAI_API_KEY is required (pass api_key: or set the env var)"
46
+ }
47
+ @default_model = model || DEFAULT_MODEL
48
+ end
49
+
50
+ # @param node [Inquirex::LLM::Node] the current LLM step
51
+ # @param answers [Hash] all collected answers so far
52
+ # @return [Hash] structured data matching the node's schema
53
+ # @raise [Errors::AdapterError] on API / parse failures
54
+ # @raise [Errors::SchemaViolationError] when the LLM output misses schema fields
55
+ def call(node, answers = {})
56
+ source = source_answers(node, answers)
57
+ model = resolve_model(node)
58
+ temperature = node.respond_to?(:temperature) ? (node.temperature || 0.2) : 0.2
59
+ max_tokens = node.respond_to?(:max_tokens) ? (node.max_tokens || DEFAULT_MAX_TOKENS) : DEFAULT_MAX_TOKENS
60
+
61
+ response = call_api(
62
+ model: model,
63
+ system: build_system_prompt(node),
64
+ user: build_user_prompt(node, source, answers),
65
+ temperature: temperature,
66
+ max_tokens: max_tokens
67
+ )
68
+
69
+ result = parse_response(response)
70
+ validate_output!(node, result)
71
+ result
72
+ end
73
+
74
+ private
75
+
76
+ def resolve_model(node)
77
+ return @default_model unless node.respond_to?(:model) && node.model
78
+
79
+ MODEL_MAP[node.model.to_sym] || node.model.to_s
80
+ end
81
+
82
+ def build_system_prompt(node)
83
+ "You are a data extraction assistant for a questionnaire engine. " \
84
+ "Your job is to analyze user input and extract structured data. " \
85
+ "You MUST respond with a single valid JSON object and nothing else." +
86
+ schema_instruction(node)
87
+ end
88
+
89
+ def schema_instruction(node)
90
+ if node.respond_to?(:schema) && node.schema
91
+ schema_json = node.schema.fields.transform_values(&:to_s)
92
+ "\n\nThe JSON object MUST match this schema exactly (same keys, appropriate types):\n" \
93
+ "#{JSON.pretty_generate(schema_json)}\n\n" \
94
+ "Every key in the schema must be present in your output. Use null, \"\", 0, or [] " \
95
+ "for values the source text does not provide."
96
+ else
97
+ ""
98
+ end
99
+ end
100
+
101
+ def build_user_prompt(node, source, all_answers)
102
+ parts = []
103
+ parts << "Task: #{node.prompt}" if node.respond_to?(:prompt) && node.prompt
104
+
105
+ if source.is_a?(Hash) && source.any?
106
+ parts << "\nSource data from previous answers:"
107
+ source.each { |key, value| parts << " #{key}: #{value.inspect}" }
108
+ end
109
+
110
+ if node.respond_to?(:schema) && node.schema
111
+ parts << "\nExtract these fields as a JSON object:"
112
+ node.schema.fields.each { |field, type| parts << " #{field} (#{type})" }
113
+ end
114
+
115
+ if node.respond_to?(:from_all) && node.from_all && all_answers.any?
116
+ parts << "\nAll collected answers:"
117
+ all_answers.each { |key, value| parts << " #{key}: #{value.inspect}" }
118
+ end
119
+
120
+ parts << "\nReturn ONLY the JSON object."
121
+ parts.join("\n")
122
+ end
123
+
124
+ def call_api(model:, system:, user:, temperature:, max_tokens:)
125
+ uri = URI(API_URL)
126
+ http = Net::HTTP.new(uri.host, uri.port)
127
+ http.use_ssl = true
128
+ http.read_timeout = 30
129
+ http.open_timeout = 10
130
+
131
+ request = Net::HTTP::Post.new(uri)
132
+ request["Content-Type"] = "application/json"
133
+ request["Authorization"] = "Bearer #{@api_key}"
134
+ request.body = JSON.generate(
135
+ model: model,
136
+ max_tokens: max_tokens,
137
+ temperature: temperature,
138
+ response_format: { type: "json_object" },
139
+ messages: [
140
+ { role: "system", content: system },
141
+ { role: "user", content: user }
142
+ ]
143
+ )
144
+
145
+ warn "[inquirex-llm] Calling OpenAI #{model}..." if ENV["INQUIREX_DEBUG"]
146
+
147
+ response = http.request(request)
148
+ unless response.is_a?(Net::HTTPSuccess)
149
+ raise Errors::AdapterError, "OpenAI API error #{response.code}: #{response.body}"
150
+ end
151
+
152
+ JSON.parse(response.body)
153
+ end
154
+
155
+ def parse_response(api_response)
156
+ choices = api_response["choices"]
157
+ message = choices.is_a?(Array) ? choices.first&.dig("message") : nil
158
+ raw_text = message&.dig("content")
159
+ raise Errors::AdapterError, "No message content in OpenAI response" unless raw_text
160
+
161
+ raw_text = raw_text.to_s.strip
162
+ raw_text = raw_text.gsub(/\A```(?:json)?\s*\n?/, "").gsub(/\n?```\s*\z/, "").strip
163
+
164
+ parsed = JSON.parse(raw_text, symbolize_names: true)
165
+ raise Errors::AdapterError, "Expected JSON object from LLM, got #{parsed.class}" unless parsed.is_a?(Hash)
166
+
167
+ parsed
168
+ rescue JSON::ParserError => e
169
+ raise Errors::AdapterError,
170
+ "Failed to parse LLM response as JSON: #{e.message}\nRaw: #{raw_text.inspect}"
171
+ end
172
+ end
173
+ end
174
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Inquirex
4
4
  module LLM
5
- VERSION = "0.1.0"
5
+ VERSION = "0.4.1"
6
6
  end
7
7
  end
data/lib/inquirex/llm.rb CHANGED
@@ -9,6 +9,8 @@ require_relative "llm/schema"
9
9
  require_relative "llm/node"
10
10
  require_relative "llm/adapter"
11
11
  require_relative "llm/null_adapter"
12
+ require_relative "llm/anthropic_adapter"
13
+ require_relative "llm/openai_adapter"
12
14
  require_relative "llm/dsl/llm_step_builder"
13
15
  require_relative "llm/dsl/flow_builder"
14
16
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: inquirex-llm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Konstantin Gredeskoul
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '0.2'
18
+ version: '0.4'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '0.2'
25
+ version: '0.4'
26
26
  description: Extends the Inquirex DSL with four LLM-powered verbs — clarify, describe,
27
27
  summarize, and detour — that run server-side to extract structured data, generate
28
28
  text, and dynamically branch flows. Pluggable adapter interface keeps the gem LLM-agnostic;
@@ -35,6 +35,7 @@ extensions: []
35
35
  extra_rdoc_files: []
36
36
  files:
37
37
  - ".relaxed_rubocop.yml"
38
+ - ".rubocop_todo.yml"
38
39
  - ".secrets.baseline"
39
40
  - CHANGELOG.md
40
41
  - LICENSE.txt
@@ -47,11 +48,13 @@ files:
47
48
  - lib/inquirex-llm.rb
48
49
  - lib/inquirex/llm.rb
49
50
  - lib/inquirex/llm/adapter.rb
51
+ - lib/inquirex/llm/anthropic_adapter.rb
50
52
  - lib/inquirex/llm/dsl/flow_builder.rb
51
53
  - lib/inquirex/llm/dsl/llm_step_builder.rb
52
54
  - lib/inquirex/llm/errors.rb
53
55
  - lib/inquirex/llm/node.rb
54
56
  - lib/inquirex/llm/null_adapter.rb
57
+ - lib/inquirex/llm/openai_adapter.rb
55
58
  - lib/inquirex/llm/schema.rb
56
59
  - lib/inquirex/llm/version.rb
57
60
  - sig/inquirex/llm.rbs
@@ -78,7 +81,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
78
81
  - !ruby/object:Gem::Version
79
82
  version: '0'
80
83
  requirements: []
81
- rubygems_version: 4.0.10
84
+ rubygems_version: 4.0.16
82
85
  specification_version: 4
83
86
  summary: LLM integration verbs for the Inquirex questionnaire engine
84
87
  test_files: []