inquirex-llm 0.1.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 +7 -0
- data/.relaxed_rubocop.yml +153 -0
- data/.secrets.baseline +127 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +221 -0
- data/Rakefile +36 -0
- data/docs/badges/coverage_badge.svg +21 -0
- data/exe/inquirex-llm +3 -0
- data/justfile +38 -0
- data/lefthook.yml +35 -0
- data/lib/inquirex/llm/adapter.rb +69 -0
- data/lib/inquirex/llm/dsl/flow_builder.rb +58 -0
- data/lib/inquirex/llm/dsl/llm_step_builder.rb +178 -0
- data/lib/inquirex/llm/errors.rb +19 -0
- data/lib/inquirex/llm/node.rb +136 -0
- data/lib/inquirex/llm/null_adapter.rb +49 -0
- data/lib/inquirex/llm/schema.rb +110 -0
- data/lib/inquirex/llm/version.rb +7 -0
- data/lib/inquirex/llm.rb +43 -0
- data/lib/inquirex-llm.rb +3 -0
- data/sig/inquirex/llm.rbs +6 -0
- metadata +84 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
module LLM
|
|
5
|
+
# Abstract interface for LLM adapters. Adapters bridge the gap between
|
|
6
|
+
# LLM::Node definitions and actual LLM API calls.
|
|
7
|
+
#
|
|
8
|
+
# Implementations must:
|
|
9
|
+
# 1. Accept an LLM::Node and current answers
|
|
10
|
+
# 2. Construct the appropriate prompt (using node.prompt, node.from_steps, etc.)
|
|
11
|
+
# 3. Call the LLM API
|
|
12
|
+
# 4. Parse and validate the response against node.schema (if present)
|
|
13
|
+
# 5. Return a Hash or String result
|
|
14
|
+
#
|
|
15
|
+
# The adapter is invoked server-side when the engine reaches an LLM step.
|
|
16
|
+
# It is never called on the frontend.
|
|
17
|
+
#
|
|
18
|
+
# @example Implementing a custom adapter
|
|
19
|
+
# class MyLlmAdapter < Inquirex::LLM::Adapter
|
|
20
|
+
# def call(node, answers)
|
|
21
|
+
# prompt_text = build_prompt(node, answers)
|
|
22
|
+
# response = my_llm_client.complete(prompt_text, model: node.model)
|
|
23
|
+
# parse_response(response, node.schema)
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
class Adapter
|
|
27
|
+
# Processes an LLM step and returns the result.
|
|
28
|
+
#
|
|
29
|
+
# @param node [LLM::Node] the LLM step to process
|
|
30
|
+
# @param answers [Hash] current collected answers
|
|
31
|
+
# @return [Hash, String] structured output (for clarify/detour) or text (for describe/summarize)
|
|
32
|
+
# @raise [Errors::AdapterError] if the LLM call fails
|
|
33
|
+
# @raise [Errors::SchemaViolationError] if output doesn't match schema
|
|
34
|
+
def call(node, answers)
|
|
35
|
+
raise NotImplementedError, "#{self.class}#call must be implemented"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Gathers the source answer data that feeds the LLM prompt.
|
|
39
|
+
#
|
|
40
|
+
# @param node [LLM::Node]
|
|
41
|
+
# @param answers [Hash]
|
|
42
|
+
# @return [Hash] relevant subset of answers
|
|
43
|
+
def source_answers(node, answers)
|
|
44
|
+
if node.from_all
|
|
45
|
+
answers.dup
|
|
46
|
+
else
|
|
47
|
+
node.from_steps.each_with_object({}) do |step_id, acc|
|
|
48
|
+
acc[step_id] = answers[step_id] if answers.key?(step_id)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Validates adapter output against the node's schema.
|
|
54
|
+
#
|
|
55
|
+
# @param node [LLM::Node]
|
|
56
|
+
# @param output [Hash, String]
|
|
57
|
+
# @raise [Errors::SchemaViolationError] if validation fails
|
|
58
|
+
def validate_output!(node, output)
|
|
59
|
+
return unless node.schema
|
|
60
|
+
|
|
61
|
+
missing = node.schema.missing_fields(output)
|
|
62
|
+
return if missing.empty?
|
|
63
|
+
|
|
64
|
+
raise Errors::SchemaViolationError,
|
|
65
|
+
"LLM output for #{node.id.inspect} missing fields: #{missing.join(", ")}"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
module LLM
|
|
5
|
+
module DSL
|
|
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
|
|
9
|
+
# without needing a separate entry point.
|
|
10
|
+
#
|
|
11
|
+
# All core verbs (ask, say, header, btw, warning, confirm) remain
|
|
12
|
+
# unchanged — LLM verbs are purely additive.
|
|
13
|
+
module FlowBuilderExtension
|
|
14
|
+
# Defines an LLM extraction step: takes free-text input and produces
|
|
15
|
+
# structured data matching the declared schema.
|
|
16
|
+
#
|
|
17
|
+
# @param id [Symbol] step id
|
|
18
|
+
def clarify(id, &)
|
|
19
|
+
add_llm_step(id, :clarify, &)
|
|
20
|
+
end
|
|
21
|
+
|
|
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
|
|
29
|
+
|
|
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
|
|
37
|
+
|
|
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, &)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# Uses the standard Ruby builder pattern (same as core FlowBuilder#add_step).
|
|
50
|
+
def add_llm_step(id, verb, &block)
|
|
51
|
+
builder = LlmStepBuilder.new(verb)
|
|
52
|
+
builder.instance_eval(&block) if block
|
|
53
|
+
@nodes[id.to_sym] = builder.build(id)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
module LLM
|
|
5
|
+
module DSL
|
|
6
|
+
# Builds an LLM::Node from a step DSL block. Handles the LLM-specific
|
|
7
|
+
# methods (from, prompt, schema, model, temperature, max_tokens, fallback)
|
|
8
|
+
# while inheriting transition and skip_if from the core StepBuilder.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# clarify :business_extracted do
|
|
12
|
+
# from :business_description
|
|
13
|
+
# prompt "Extract structured business info."
|
|
14
|
+
# schema industry: :string, employee_count: :integer
|
|
15
|
+
# model :claude_sonnet
|
|
16
|
+
# temperature 0.2
|
|
17
|
+
# transition to: :next_step
|
|
18
|
+
# end
|
|
19
|
+
class LlmStepBuilder
|
|
20
|
+
include Inquirex::DSL::RuleHelpers
|
|
21
|
+
|
|
22
|
+
def initialize(verb)
|
|
23
|
+
@verb = verb.to_sym
|
|
24
|
+
@prompt = nil
|
|
25
|
+
@schema_fields = {}
|
|
26
|
+
@from_steps = []
|
|
27
|
+
@from_all = false
|
|
28
|
+
@model = nil
|
|
29
|
+
@temperature = nil
|
|
30
|
+
@max_tokens = nil
|
|
31
|
+
@fallback = nil
|
|
32
|
+
@transitions = []
|
|
33
|
+
@skip_if = nil
|
|
34
|
+
@question = nil
|
|
35
|
+
@text = nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Sets the LLM prompt template. Use {{field_name}} for interpolation
|
|
39
|
+
# placeholders that the adapter resolves at runtime.
|
|
40
|
+
#
|
|
41
|
+
# @param text [String]
|
|
42
|
+
def prompt(text)
|
|
43
|
+
@prompt = text
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Declares the expected output schema. Each key is a field name,
|
|
47
|
+
# each value is an Inquirex data type symbol.
|
|
48
|
+
#
|
|
49
|
+
# @param fields [Hash{Symbol => Symbol}]
|
|
50
|
+
def schema(**fields)
|
|
51
|
+
@schema_fields.merge!(fields)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Adds source step id(s) whose answers feed the LLM prompt.
|
|
55
|
+
#
|
|
56
|
+
# @param step_ids [Symbol, Array<Symbol>] one or more step ids
|
|
57
|
+
def from(*step_ids)
|
|
58
|
+
@from_steps.concat(step_ids.flatten)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Passes all collected answers to the LLM prompt.
|
|
62
|
+
#
|
|
63
|
+
# @param value [Boolean]
|
|
64
|
+
def from_all(value = true)
|
|
65
|
+
@from_all = !!value
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Optional model hint for the adapter.
|
|
69
|
+
#
|
|
70
|
+
# @param name [Symbol] e.g. :claude_sonnet, :claude_haiku
|
|
71
|
+
def model(name)
|
|
72
|
+
@model = name.to_sym
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Optional sampling temperature.
|
|
76
|
+
#
|
|
77
|
+
# @param value [Float]
|
|
78
|
+
def temperature(value)
|
|
79
|
+
@temperature = value.to_f
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Optional maximum output tokens.
|
|
83
|
+
#
|
|
84
|
+
# @param value [Integer]
|
|
85
|
+
def max_tokens(value)
|
|
86
|
+
@max_tokens = value.to_i
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Server-side fallback block, invoked when the LLM call fails.
|
|
90
|
+
# Stripped from JSON serialization.
|
|
91
|
+
#
|
|
92
|
+
# @yield [Hash] answers collected so far
|
|
93
|
+
# @return [Object] fallback value to store as the answer
|
|
94
|
+
def fallback(&block)
|
|
95
|
+
@fallback = block
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Adds a conditional transition. Inherited concept from core DSL.
|
|
99
|
+
# All LLM transitions are implicitly requires_server: true.
|
|
100
|
+
#
|
|
101
|
+
# @param to [Symbol] target step id
|
|
102
|
+
# @param if_rule [Rules::Base, nil]
|
|
103
|
+
# @param requires_server [Boolean]
|
|
104
|
+
def transition(to:, if_rule: nil, requires_server: true)
|
|
105
|
+
@transitions << Inquirex::Transition.new(target: to, rule: if_rule, requires_server:)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Sets a rule that skips this step entirely when true.
|
|
109
|
+
#
|
|
110
|
+
# @param rule [Rules::Base]
|
|
111
|
+
def skip_if(rule)
|
|
112
|
+
@skip_if = rule
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Optional display text (used by describe/summarize for user-visible labels).
|
|
116
|
+
#
|
|
117
|
+
# @param content [String]
|
|
118
|
+
def question(content)
|
|
119
|
+
@question = content
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Optional display text for context.
|
|
123
|
+
#
|
|
124
|
+
# @param content [String]
|
|
125
|
+
def text(content)
|
|
126
|
+
@text = content
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Builds the LLM::Node.
|
|
130
|
+
#
|
|
131
|
+
# @param id [Symbol]
|
|
132
|
+
# @return [LLM::Node]
|
|
133
|
+
# @raise [Errors::DefinitionError] if prompt is missing
|
|
134
|
+
def build(id)
|
|
135
|
+
validate!(id)
|
|
136
|
+
|
|
137
|
+
schema_obj = @schema_fields.empty? ? nil : Schema.new(**@schema_fields)
|
|
138
|
+
|
|
139
|
+
LLM::Node.new(
|
|
140
|
+
id:,
|
|
141
|
+
verb: @verb,
|
|
142
|
+
prompt: @prompt,
|
|
143
|
+
schema: schema_obj,
|
|
144
|
+
from_steps: @from_steps,
|
|
145
|
+
from_all: @from_all,
|
|
146
|
+
model: @model,
|
|
147
|
+
temperature: @temperature,
|
|
148
|
+
max_tokens: @max_tokens,
|
|
149
|
+
fallback: @fallback,
|
|
150
|
+
question: @question,
|
|
151
|
+
text: @text,
|
|
152
|
+
transitions: @transitions,
|
|
153
|
+
skip_if: @skip_if
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
def validate!(id)
|
|
160
|
+
raise Errors::DefinitionError, "LLM step #{id.inspect} requires a prompt" if @prompt.nil?
|
|
161
|
+
|
|
162
|
+
if %i[clarify detour].include?(@verb) && @schema_fields.empty?
|
|
163
|
+
raise Errors::DefinitionError,
|
|
164
|
+
"LLM step #{id.inspect} (#{@verb}) requires a schema"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
return unless @from_steps.empty? && !@from_all && @verb != :summarize
|
|
168
|
+
|
|
169
|
+
# clarify/describe/detour should reference source steps or from_all
|
|
170
|
+
return unless %i[clarify describe detour].include?(@verb)
|
|
171
|
+
|
|
172
|
+
raise Errors::DefinitionError,
|
|
173
|
+
"LLM step #{id.inspect} (#{@verb}) requires `from` or `from_all`"
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
module LLM
|
|
5
|
+
module Errors
|
|
6
|
+
# Base exception for all LLM-related errors.
|
|
7
|
+
class Error < Inquirex::Errors::Error; end
|
|
8
|
+
|
|
9
|
+
# Raised when an LLM step definition is invalid (e.g. missing prompt, bad schema).
|
|
10
|
+
class DefinitionError < Error; end
|
|
11
|
+
|
|
12
|
+
# Raised when the LLM adapter returns output that doesn't match the declared schema.
|
|
13
|
+
class SchemaViolationError < Error; end
|
|
14
|
+
|
|
15
|
+
# Raised when the LLM adapter call fails after exhausting retries.
|
|
16
|
+
class AdapterError < Error; end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
module LLM
|
|
5
|
+
# Enriched node for LLM-powered steps. Extends Inquirex::Node with attributes
|
|
6
|
+
# needed by the server-side LLM adapter: prompt template, output schema, source
|
|
7
|
+
# step references, and model configuration.
|
|
8
|
+
#
|
|
9
|
+
# LLM verbs:
|
|
10
|
+
# :clarify — extract structured data from a free-text answer
|
|
11
|
+
# :describe — generate natural-language text from structured data
|
|
12
|
+
# :summarize — produce a summary of all or selected answers
|
|
13
|
+
# :detour — dynamically generate follow-up questions based on an answer
|
|
14
|
+
#
|
|
15
|
+
# All LLM nodes are collecting (they produce answers) and require server
|
|
16
|
+
# round-trips. The frontend shows a "thinking" state while the server processes.
|
|
17
|
+
#
|
|
18
|
+
# @attr_reader prompt [String] LLM prompt template
|
|
19
|
+
# @attr_reader schema [Schema, nil] expected output structure (required for clarify/detour)
|
|
20
|
+
# @attr_reader from_steps [Array<Symbol>] source step ids whose answers feed the LLM
|
|
21
|
+
# @attr_reader from_all [Boolean] whether to pass all collected answers to the LLM
|
|
22
|
+
# @attr_reader model [Symbol, nil] optional model hint (e.g. :claude_sonnet)
|
|
23
|
+
# @attr_reader temperature [Float, nil] optional sampling temperature
|
|
24
|
+
# @attr_reader max_tokens [Integer, nil] optional max output tokens
|
|
25
|
+
# @attr_reader fallback [Proc, nil] server-side fallback (stripped from JSON)
|
|
26
|
+
class Node < Inquirex::Node
|
|
27
|
+
LLM_VERBS = %i[clarify describe summarize detour].freeze
|
|
28
|
+
|
|
29
|
+
attr_reader :prompt,
|
|
30
|
+
:schema,
|
|
31
|
+
:from_steps,
|
|
32
|
+
:from_all,
|
|
33
|
+
:model,
|
|
34
|
+
:temperature,
|
|
35
|
+
:max_tokens,
|
|
36
|
+
:fallback
|
|
37
|
+
|
|
38
|
+
def initialize(prompt:, schema: nil, from_steps: [], from_all: false,
|
|
39
|
+
model: nil, temperature: nil, max_tokens: nil, fallback: nil, **)
|
|
40
|
+
@prompt = prompt
|
|
41
|
+
@schema = schema
|
|
42
|
+
@from_steps = Array(from_steps).map(&:to_sym).freeze
|
|
43
|
+
@from_all = !!from_all
|
|
44
|
+
@model = model&.to_sym
|
|
45
|
+
@temperature = temperature&.to_f
|
|
46
|
+
@max_tokens = max_tokens&.to_i
|
|
47
|
+
@fallback = fallback
|
|
48
|
+
super(**)
|
|
49
|
+
end
|
|
50
|
+
# rubocop:enable Metrics/ParameterLists
|
|
51
|
+
|
|
52
|
+
# LLM verbs always collect output (the LLM provides the "answer").
|
|
53
|
+
def collecting? = true
|
|
54
|
+
|
|
55
|
+
# LLM verbs are never display-only.
|
|
56
|
+
def display? = false
|
|
57
|
+
|
|
58
|
+
# Whether this is an LLM-powered step requiring server processing.
|
|
59
|
+
def llm_verb? = true
|
|
60
|
+
|
|
61
|
+
# Serializes to a plain Hash. LLM metadata is nested under "llm".
|
|
62
|
+
# Fallback procs are stripped (server-side only).
|
|
63
|
+
# All transitions are marked requires_server: true.
|
|
64
|
+
#
|
|
65
|
+
# @return [Hash]
|
|
66
|
+
def to_h
|
|
67
|
+
hash = { "verb" => @verb.to_s }
|
|
68
|
+
hash["question"] = @question if @question
|
|
69
|
+
hash["text"] = @text if @text
|
|
70
|
+
hash["transitions"] = @transitions.map(&:to_h) unless @transitions.empty?
|
|
71
|
+
hash["skip_if"] = @skip_if.to_h if @skip_if
|
|
72
|
+
hash["requires_server"] = true
|
|
73
|
+
|
|
74
|
+
llm_hash = { "prompt" => @prompt }
|
|
75
|
+
llm_hash["schema"] = @schema.to_h if @schema
|
|
76
|
+
llm_hash["from_steps"] = @from_steps.map(&:to_s) unless @from_steps.empty?
|
|
77
|
+
llm_hash["from_all"] = true if @from_all
|
|
78
|
+
llm_hash["model"] = @model.to_s if @model
|
|
79
|
+
llm_hash["temperature"] = @temperature if @temperature
|
|
80
|
+
llm_hash["max_tokens"] = @max_tokens if @max_tokens
|
|
81
|
+
hash["llm"] = llm_hash
|
|
82
|
+
|
|
83
|
+
hash
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Deserializes from a plain Hash (string or symbol keys).
|
|
87
|
+
#
|
|
88
|
+
# @param id [Symbol, String]
|
|
89
|
+
# @param hash [Hash]
|
|
90
|
+
# @return [LLM::Node]
|
|
91
|
+
def self.from_h(id, hash)
|
|
92
|
+
verb = hash["verb"] || hash[:verb]
|
|
93
|
+
question = hash["question"] || hash[:question]
|
|
94
|
+
text = hash["text"] || hash[:text]
|
|
95
|
+
transitions_data = hash["transitions"] || hash[:transitions] || []
|
|
96
|
+
skip_if_data = hash["skip_if"] || hash[:skip_if]
|
|
97
|
+
llm_data = hash["llm"] || hash[:llm] || {}
|
|
98
|
+
|
|
99
|
+
transitions = transitions_data.map { |t| Inquirex::Transition.from_h(t) }
|
|
100
|
+
skip_if = skip_if_data ? Inquirex::Rules::Base.from_h(skip_if_data) : nil
|
|
101
|
+
|
|
102
|
+
prompt = llm_data["prompt"] || llm_data[:prompt]
|
|
103
|
+
schema_raw = llm_data["schema"] || llm_data[:schema]
|
|
104
|
+
from_raw = llm_data["from_steps"] || llm_data[:from_steps] || []
|
|
105
|
+
from_all = llm_data["from_all"] || llm_data[:from_all] || false
|
|
106
|
+
model = llm_data["model"] || llm_data[:model]
|
|
107
|
+
temp = llm_data["temperature"] || llm_data[:temperature]
|
|
108
|
+
max_tok = llm_data["max_tokens"] || llm_data[:max_tokens]
|
|
109
|
+
|
|
110
|
+
schema = schema_raw ? Schema.from_h(schema_raw) : nil
|
|
111
|
+
from_steps = from_raw.map(&:to_sym)
|
|
112
|
+
|
|
113
|
+
new(
|
|
114
|
+
id:,
|
|
115
|
+
verb:,
|
|
116
|
+
question:,
|
|
117
|
+
text:,
|
|
118
|
+
transitions:,
|
|
119
|
+
skip_if:,
|
|
120
|
+
prompt:,
|
|
121
|
+
schema:,
|
|
122
|
+
from_steps:,
|
|
123
|
+
from_all:,
|
|
124
|
+
model:,
|
|
125
|
+
temperature: temp,
|
|
126
|
+
max_tokens: max_tok
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Whether this verb is a recognized LLM verb.
|
|
131
|
+
def self.llm_verb?(verb)
|
|
132
|
+
LLM_VERBS.include?(verb.to_sym)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
module LLM
|
|
5
|
+
# Test adapter that returns schema-conformant placeholder values without
|
|
6
|
+
# calling any LLM API. Useful for testing flows that include LLM steps.
|
|
7
|
+
#
|
|
8
|
+
# For clarify/detour steps with a schema, returns a hash of default values
|
|
9
|
+
# matching each field's declared type. For describe/summarize steps, returns
|
|
10
|
+
# a placeholder string.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# adapter = Inquirex::LLM::NullAdapter.new
|
|
14
|
+
# result = adapter.call(clarify_node, answers)
|
|
15
|
+
# # => { industry: "", employee_count: 0, ... }
|
|
16
|
+
class NullAdapter < Adapter
|
|
17
|
+
TYPE_DEFAULTS = {
|
|
18
|
+
string: "",
|
|
19
|
+
text: "",
|
|
20
|
+
integer: 0,
|
|
21
|
+
decimal: 0.0,
|
|
22
|
+
currency: 0.0,
|
|
23
|
+
boolean: false,
|
|
24
|
+
enum: "",
|
|
25
|
+
multi_enum: [],
|
|
26
|
+
date: "",
|
|
27
|
+
email: "",
|
|
28
|
+
phone: "",
|
|
29
|
+
array: [],
|
|
30
|
+
hash: {}
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
# Returns placeholder output matching the node's schema or verb.
|
|
34
|
+
#
|
|
35
|
+
# @param node [LLM::Node]
|
|
36
|
+
# @param _answers [Hash] ignored
|
|
37
|
+
# @return [Hash, String]
|
|
38
|
+
def call(node, _answers = {})
|
|
39
|
+
if node.schema
|
|
40
|
+
node.schema.fields.each_with_object({}) do |(name, type), acc|
|
|
41
|
+
acc[name] = TYPE_DEFAULTS.fetch(type, "")
|
|
42
|
+
end
|
|
43
|
+
else
|
|
44
|
+
"(placeholder #{node.verb} output for #{node.id})"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
module LLM
|
|
5
|
+
# Immutable definition of expected LLM output structure.
|
|
6
|
+
# Each field maps a name to an Inquirex data type, forming the contract
|
|
7
|
+
# between the LLM prompt and the structured data it must return.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# schema = Schema.new(
|
|
11
|
+
# industry: :string,
|
|
12
|
+
# entity_type: :enum,
|
|
13
|
+
# employee_count: :integer,
|
|
14
|
+
# estimated_revenue: :currency
|
|
15
|
+
# )
|
|
16
|
+
# schema.fields # => { industry: :string, ... }
|
|
17
|
+
# schema.field_names # => [:industry, :entity_type, ...]
|
|
18
|
+
# schema.valid_output?({ industry: "Tech", ... }) # => true
|
|
19
|
+
#
|
|
20
|
+
# @attr_reader fields [Hash{Symbol => Symbol}] field_name => type mapping
|
|
21
|
+
class Schema
|
|
22
|
+
VALID_TYPES = %i[
|
|
23
|
+
string text integer decimal currency boolean
|
|
24
|
+
enum multi_enum date email phone
|
|
25
|
+
array hash
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
attr_reader :fields
|
|
29
|
+
|
|
30
|
+
# @param field_map [Hash{Symbol => Symbol}] field_name => type
|
|
31
|
+
# @raise [Errors::DefinitionError] if any type is unrecognized
|
|
32
|
+
def initialize(**field_map)
|
|
33
|
+
raise Errors::DefinitionError, "Schema must have at least one field" if field_map.empty?
|
|
34
|
+
|
|
35
|
+
validate_types!(field_map)
|
|
36
|
+
@fields = field_map.transform_keys(&:to_sym)
|
|
37
|
+
.transform_values(&:to_sym)
|
|
38
|
+
.freeze
|
|
39
|
+
freeze
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [Array<Symbol>] ordered list of field names
|
|
43
|
+
def field_names = @fields.keys
|
|
44
|
+
|
|
45
|
+
# @return [Integer] number of fields
|
|
46
|
+
def size = @fields.size
|
|
47
|
+
|
|
48
|
+
# Checks whether a Hash output conforms to the schema (all declared fields present).
|
|
49
|
+
#
|
|
50
|
+
# @param output [Hash] LLM output to validate
|
|
51
|
+
# @return [Boolean]
|
|
52
|
+
def valid_output?(output)
|
|
53
|
+
return false unless output.is_a?(Hash)
|
|
54
|
+
|
|
55
|
+
symbolized = output.transform_keys(&:to_sym)
|
|
56
|
+
@fields.keys.all? { |key| symbolized.key?(key) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Returns the list of fields missing from the given output.
|
|
60
|
+
#
|
|
61
|
+
# @param output [Hash]
|
|
62
|
+
# @return [Array<Symbol>]
|
|
63
|
+
def missing_fields(output)
|
|
64
|
+
return field_names unless output.is_a?(Hash)
|
|
65
|
+
|
|
66
|
+
symbolized = output.transform_keys(&:to_sym)
|
|
67
|
+
@fields.keys.reject { |key| symbolized.key?(key) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @return [Hash]
|
|
71
|
+
def to_h
|
|
72
|
+
@fields.transform_keys(&:to_s).transform_values(&:to_s)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [String] JSON representation
|
|
76
|
+
def to_json(*)
|
|
77
|
+
JSON.generate(to_h)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @param hash [Hash] string or symbol keys, values are type names
|
|
81
|
+
# @return [Schema]
|
|
82
|
+
def self.from_h(hash)
|
|
83
|
+
field_map = hash.each_with_object({}) do |(k, v), acc|
|
|
84
|
+
acc[k.to_sym] = v.to_sym
|
|
85
|
+
end
|
|
86
|
+
new(**field_map)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def ==(other)
|
|
90
|
+
other.is_a?(Schema) && @fields == other.fields
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def inspect
|
|
94
|
+
"#<Inquirex::LLM::Schema #{@fields.map { |k, v| "#{k}:#{v}" }.join(", ")}>"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def validate_types!(field_map)
|
|
100
|
+
field_map.each do |name, type|
|
|
101
|
+
next if VALID_TYPES.include?(type.to_sym)
|
|
102
|
+
|
|
103
|
+
raise Errors::DefinitionError,
|
|
104
|
+
"Unknown type #{type.inspect} for field #{name.inspect}. " \
|
|
105
|
+
"Valid types: #{VALID_TYPES.join(", ")}"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
data/lib/inquirex/llm.rb
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "inquirex"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
require_relative "llm/version"
|
|
7
|
+
require_relative "llm/errors"
|
|
8
|
+
require_relative "llm/schema"
|
|
9
|
+
require_relative "llm/node"
|
|
10
|
+
require_relative "llm/adapter"
|
|
11
|
+
require_relative "llm/null_adapter"
|
|
12
|
+
require_relative "llm/dsl/llm_step_builder"
|
|
13
|
+
require_relative "llm/dsl/flow_builder"
|
|
14
|
+
|
|
15
|
+
module Inquirex
|
|
16
|
+
# LLM integration layer for Inquirex flows.
|
|
17
|
+
#
|
|
18
|
+
# Extends the core DSL with four LLM-powered verbs that run server-side:
|
|
19
|
+
# - clarify — extract structured data from free-text answers
|
|
20
|
+
# - describe — generate natural-language text from structured data
|
|
21
|
+
# - summarize — produce a summary of all or selected answers
|
|
22
|
+
# - detour — dynamically generate follow-up questions
|
|
23
|
+
#
|
|
24
|
+
# LLM calls never happen on the frontend. Steps are marked `requires_server: true`
|
|
25
|
+
# in the JSON wire format so the JS widget knows to round-trip to the server.
|
|
26
|
+
#
|
|
27
|
+
# Usage:
|
|
28
|
+
# require "inquirex"
|
|
29
|
+
# require "inquirex-llm"
|
|
30
|
+
#
|
|
31
|
+
# Inquirex.define id: "intake" do
|
|
32
|
+
# start :description
|
|
33
|
+
# ask(:description) { type :text; question "Describe your business."; transition to: :extracted }
|
|
34
|
+
# clarify(:extracted) { from :description; prompt "Extract info."; schema name: :string; transition to: :done }
|
|
35
|
+
# say(:done) { text "Done!" }
|
|
36
|
+
# end
|
|
37
|
+
module LLM
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Inject LLM verbs into the core FlowBuilder so that Inquirex.define
|
|
42
|
+
# gains clarify/describe/summarize/detour when this gem is loaded.
|
|
43
|
+
Inquirex::DSL::FlowBuilder.include(Inquirex::LLM::DSL::FlowBuilderExtension)
|
data/lib/inquirex-llm.rb
ADDED