inquirex-llm 0.1.0 → 0.3.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/.rubocop_todo.yml +28 -0
- data/README.md +105 -0
- data/docs/badges/coverage_badge.svg +2 -2
- data/lib/inquirex/llm/anthropic_adapter.rb +168 -0
- data/lib/inquirex/llm/openai_adapter.rb +174 -0
- data/lib/inquirex/llm/version.rb +1 -1
- data/lib/inquirex/llm.rb +2 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 737239fae63381a1b7ab52afd9a811e6d57f4b781379c8dbaa694c7458e940a2
|
|
4
|
+
data.tar.gz: 531baf672a53604111e9799bdb2a1af4bfac01933a304589da6c68655db57dfb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5a372a04fd14caddf249fd5b04d63f87508dc468ea50de016f57ac968814f4b8641fe96e6b05f05bc433a2c77978ffd120b4529a3ae5590867da46eb4e5f00e4
|
|
7
|
+
data.tar.gz: f8b604991408d6b61a063a9f337e32fe8de96c0de5b6eda7bf08dfe496f19756833abae6c3437cf6078c9efe5ff5230c05f14f3b0ea2e205b68c9efc8d7bb416
|
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
|
@@ -159,6 +159,111 @@ result = adapter.call(engine.current_step)
|
|
|
159
159
|
# => { industry: "", employee_count: 0, revenue: 0.0 }
|
|
160
160
|
```
|
|
161
161
|
|
|
162
|
+
## Built-in Adapters
|
|
163
|
+
|
|
164
|
+
| Class | Provider | API | Auth | Key env var |
|
|
165
|
+
|------------------------------------|-----------|---------------------------------------|-----------------------------|-----------------------|
|
|
166
|
+
| `Inquirex::LLM::NullAdapter` | — | none (placeholders) | none | — |
|
|
167
|
+
| `Inquirex::LLM::AnthropicAdapter` | Anthropic | `/v1/messages` | `x-api-key` header | `ANTHROPIC_API_KEY` |
|
|
168
|
+
| `Inquirex::LLM::OpenAIAdapter` | OpenAI | `/v1/chat/completions` (JSON mode) | `Authorization: Bearer …` | `OPENAI_API_KEY` |
|
|
169
|
+
|
|
170
|
+
Both real adapters use `net/http` (stdlib, no extra dependency), inject the
|
|
171
|
+
declared `schema` into the system prompt as a strict JSON contract, and raise
|
|
172
|
+
`Inquirex::LLM::Errors::AdapterError` on HTTP / parse failures and
|
|
173
|
+
`SchemaViolationError` when the model's output is missing declared fields.
|
|
174
|
+
|
|
175
|
+
### AnthropicAdapter
|
|
176
|
+
|
|
177
|
+
```ruby
|
|
178
|
+
adapter = Inquirex::LLM::AnthropicAdapter.new(
|
|
179
|
+
api_key: ENV["ANTHROPIC_API_KEY"],
|
|
180
|
+
model: "claude-sonnet-4-20250514" # or pass the short symbol in the DSL
|
|
181
|
+
)
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Recognized `model :symbol` values in the DSL: `:claude_sonnet`,
|
|
185
|
+
`:claude_haiku`, `:claude_opus` (mapped to the current concrete model ids).
|
|
186
|
+
|
|
187
|
+
### OpenAIAdapter
|
|
188
|
+
|
|
189
|
+
```ruby
|
|
190
|
+
adapter = Inquirex::LLM::OpenAIAdapter.new(
|
|
191
|
+
api_key: ENV["OPENAI_API_KEY"],
|
|
192
|
+
model: "gpt-4o-mini"
|
|
193
|
+
)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Uses Chat Completions with `response_format: { type: "json_object" }` so the
|
|
197
|
+
model is constrained to return valid JSON. Recognized DSL symbols: `:gpt_4o`,
|
|
198
|
+
`:gpt_4o_mini`, `:gpt_4_1`, `:gpt_4_1_mini`. For cross-provider portability,
|
|
199
|
+
the adapter also accepts the Claude symbols (`:claude_sonnet` → `gpt-4o` etc.)
|
|
200
|
+
so a flow file that says `model :claude_sonnet` runs unchanged against either
|
|
201
|
+
provider.
|
|
202
|
+
|
|
203
|
+
## LLM-assisted Pre-fill Pattern
|
|
204
|
+
|
|
205
|
+
A common use case: ask *one* open-ended question, let the LLM extract answers
|
|
206
|
+
for *many* downstream questions, and only prompt the user for what the LLM
|
|
207
|
+
couldn't determine. This is what the core engine's `Engine#prefill!` is for:
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
definition = Inquirex.define id: "tax-intake" do
|
|
211
|
+
start :describe
|
|
212
|
+
|
|
213
|
+
ask :describe do
|
|
214
|
+
type :text
|
|
215
|
+
question "Describe your 2025 tax situation."
|
|
216
|
+
transition to: :extracted
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
clarify :extracted do
|
|
220
|
+
from :describe
|
|
221
|
+
prompt "Extract: filing_status, dependents, income_types, state_filing."
|
|
222
|
+
schema filing_status: :string,
|
|
223
|
+
dependents: :integer,
|
|
224
|
+
income_types: :multi_enum,
|
|
225
|
+
state_filing: :string
|
|
226
|
+
model :claude_sonnet
|
|
227
|
+
transition to: :filing_status
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
ask :filing_status do
|
|
231
|
+
type :enum
|
|
232
|
+
question "Filing status?"
|
|
233
|
+
options %w[single married_filing_jointly head_of_household]
|
|
234
|
+
skip_if not_empty(:filing_status) # ← the whole trick
|
|
235
|
+
transition to: :dependents
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
ask :dependents do
|
|
239
|
+
type :integer
|
|
240
|
+
question "How many dependents?"
|
|
241
|
+
skip_if not_empty(:dependents)
|
|
242
|
+
transition to: :income_types
|
|
243
|
+
end
|
|
244
|
+
# …and so on for every field in the clarify schema
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
engine = Inquirex::Engine.new(definition)
|
|
248
|
+
adapter = Inquirex::LLM::OpenAIAdapter.new # or AnthropicAdapter
|
|
249
|
+
|
|
250
|
+
engine.answer("I'm MFJ with two kids in California, W-2 plus some crypto.")
|
|
251
|
+
result = adapter.call(engine.current_step, engine.answers)
|
|
252
|
+
engine.answer(result) # stored under :extracted
|
|
253
|
+
engine.prefill!(result) # splats into top-level answers
|
|
254
|
+
|
|
255
|
+
# Every downstream step whose skip_if rule now evaluates true gets
|
|
256
|
+
# auto-skipped by the engine. engine.current_step_id jumps straight to
|
|
257
|
+
# whichever field the LLM couldn't fill in.
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
`Engine#prefill!` is non-destructive (won't clobber an answer the user already
|
|
261
|
+
gave), ignores `nil`/empty values so they don't spuriously trigger
|
|
262
|
+
`not_empty`, and auto-advances past any step whose `skip_if` now evaluates
|
|
263
|
+
true. See [examples/09_tax_preparer_llm.rb](../inquirex-tty/examples/09_tax_preparer_llm.rb)
|
|
264
|
+
for a complete runnable flow, or the repo-level `demo_llm_intake.rb` for a
|
|
265
|
+
scripted end-to-end walkthrough.
|
|
266
|
+
|
|
162
267
|
## JSON Serialization
|
|
163
268
|
|
|
164
269
|
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">
|
|
19
|
-
<text x="80" y="14">
|
|
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>
|
|
@@ -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
|
data/lib/inquirex/llm/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Konstantin Gredeskoul
|
|
@@ -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
|