inquirex 0.7.0 → 0.9.4

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.
@@ -16,28 +16,24 @@ module Inquirex
16
16
  @nodes = {}
17
17
  @meta = {}
18
18
  @accumulators = {}
19
- @actions = []
20
- @allowed_domains = []
21
- end
22
-
23
- # Declares the domains outbound effects (webhook) may send answers to.
24
- # Conventionally the first declaration in a definition, so the flow's
25
- # egress surface is auditable at a glance. "example.com" matches that
26
- # host exactly; "*.example.com" matches its subdomains.
27
- #
28
- # @param domains [Array<String>]
29
- def allowed_domains(*domains)
30
- @allowed_domains.concat(domains.flatten)
19
+ @send_emails = []
31
20
  end
32
21
 
33
22
  # Declares a named running total the flow accumulates into as answers come in.
34
23
  # The `:price` accumulator is the common lead-qualification use case; others
35
24
  # (e.g. :complexity, :credit_score) work identically.
36
25
  #
37
- # @param name [Symbol] e.g. :price
26
+ # A `:text` accumulator is filled by the engine rather than by
27
+ # `accumulate` declarations: it collects everything the user was shown
28
+ # and every answer they gave, which is what an LLM `summarize` step
29
+ # reads. See {Accumulator}.
30
+ #
31
+ # @param name [Symbol] e.g. :price, :transcript
38
32
  # @param type [Symbol] one of Node::TYPES (default :currency-ish: :decimal)
39
- # @param default [Numeric] starting value (default: 0)
40
- def accumulator(name, type: :decimal, default: 0)
33
+ # @param default [Numeric, String, nil] starting value; nil (the default)
34
+ # takes the type's own zero, so a :text accumulator starts at "" and
35
+ # every other kind starts at 0
36
+ def accumulator(name, type: :decimal, default: nil)
41
37
  sym = name.to_sym
42
38
  @accumulators[sym] = Accumulator.new(name: sym, type:, default:)
43
39
  end
@@ -116,33 +112,45 @@ module Inquirex
116
112
  add_step(id, :confirm, &)
117
113
  end
118
114
 
119
- # Declares a named post-completion action: effects (send_email, run, ...)
120
- # executed server-side after the flow finishes, with the collected
121
- # answers. Runs in declaration order; gate with a serializable rule via
122
- # the if: option.
115
+ # Declares an email the host application builds and delivers after the
116
+ # flow finishes, from the collected answers. Declarations run in order;
117
+ # gate with a serializable rule via the if: option. This is the only
118
+ # completion declaration the core DSL carries — richer post-completion
119
+ # behavior belongs to the host application.
120
+ #
121
+ # @example Receipt sent only when the visitor left an email address
122
+ # send_email if: not_empty(:email) do
123
+ # to "{{email}}"
124
+ # from "forms@agentica.group"
125
+ # subject "Thanks {{name}} — we got your inquiry"
126
+ # markdown_text <<~TEXT
127
+ # Hi {{name}},
123
128
  #
124
- # @example Email the collected answers when business income was selected
125
- # action :notify_sales, if: Rules::Contains.new(:income_types, "Business") do
126
- # send_email to: "sales@example.com",
127
- # subject: "New lead: {{name}}",
128
- # html: "{{answers_summary}}"
129
+ # We received your answers and will reply within one business day.
130
+ #
131
+ # {{answers_summary}}
132
+ # TEXT
129
133
  # end
130
134
  #
131
- # @param id [Symbol] action identifier
132
- # @param opts [Hash] only if: is recognized a Rules::Base gate
133
- # @yield block evaluated in ActionBuilder (send_email, run, ...)
134
- def action(id, **opts, &block)
135
+ # @param opts [Hash] if: takes a Rules::Base gate; any remaining keys are
136
+ # SendEmail fields (to:, subject:, text:, ...) for the inline form
137
+ # @yield block evaluated in SendEmailBuilder (to, from, subject, ...);
138
+ # block values override same-named inline keys
139
+ # @return [void]
140
+ # @raise [Errors::DefinitionError] on unknown fields or missing to:/subject:/body
141
+ def send_email(**opts, &block)
135
142
  rule = opts.delete(:if)
136
- raise Errors::DefinitionError, "Unknown action options: #{opts.keys.inspect}" unless opts.empty?
137
-
138
- sym = id.to_sym
139
- if @actions.any? { |a| a.id == sym }
140
- raise Errors::DefinitionError, "Duplicate action id: #{sym.inspect}"
143
+ params = opts
144
+ if block
145
+ builder = SendEmailBuilder.new
146
+ builder.instance_eval(&block)
147
+ params = params.merge(builder.params)
148
+ end
149
+ begin
150
+ @send_emails << SendEmail.new(**params, rule:)
151
+ rescue ArgumentError => e
152
+ raise Errors::DefinitionError, "send_email: #{e.message}"
141
153
  end
142
-
143
- builder = ActionBuilder.new
144
- builder.instance_eval(&block) if block
145
- @actions << builder.build(sym, rule:)
146
154
  end
147
155
 
148
156
  # Produces the frozen Definition.
@@ -154,14 +162,13 @@ module Inquirex
154
162
  raise Errors::DefinitionError, "No steps defined" if @nodes.empty?
155
163
 
156
164
  Definition.new(
157
- start_step_id: @start_step_id,
158
- nodes: @nodes,
159
- id: @flow_id,
160
- version: @flow_version,
161
- meta: @meta,
162
- accumulators: @accumulators,
163
- actions: @actions,
164
- allowed_domains: @allowed_domains
165
+ start_step_id: @start_step_id,
166
+ nodes: @nodes,
167
+ id: @flow_id,
168
+ version: @flow_version,
169
+ meta: @meta,
170
+ accumulators: @accumulators,
171
+ send_emails: @send_emails
165
172
  )
166
173
  end
167
174
 
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inquirex
4
+ module DSL
5
+ # Collects the fields of a `send_email` block. Every setter is an explicit
6
+ # builder method — the block form mirrors the step builders (`type`,
7
+ # `question`, ...) rather than a keyword-argument hash:
8
+ #
9
+ # send_email if: not_empty(:email) do
10
+ # to "{{email}}"
11
+ # from "forms@agentica.group"
12
+ # subject "Thanks {{name}}"
13
+ # markdown_text <<~TEXT
14
+ # Hi {{name}}, we got your answers:
15
+ #
16
+ # {{answers_summary}}
17
+ # TEXT
18
+ # end
19
+ class SendEmailBuilder
20
+ # @return [Hash{Symbol => Object}] collected SendEmail constructor params
21
+ attr_reader :params
22
+
23
+ def initialize
24
+ @params = {}
25
+ end
26
+
27
+ # @param value [String] recipient template ({{field}} placeholders allowed)
28
+ # @return [void]
29
+ def to(value)
30
+ @params[:to] = value
31
+ end
32
+
33
+ # @param value [String] sender template
34
+ # @return [void]
35
+ def from(value)
36
+ @params[:from] = value
37
+ end
38
+
39
+ # @param value [String] carbon-copy template
40
+ # @return [void]
41
+ def cc(value)
42
+ @params[:cc] = value
43
+ end
44
+
45
+ # @param value [String] blind-carbon-copy template
46
+ # @return [void]
47
+ def bcc(value)
48
+ @params[:bcc] = value
49
+ end
50
+
51
+ # @param value [String] reply-to template
52
+ # @return [void]
53
+ def reply_to(value)
54
+ @params[:reply_to] = value
55
+ end
56
+
57
+ # @param value [String] subject template
58
+ # @return [void]
59
+ def subject(value)
60
+ @params[:subject] = value
61
+ end
62
+
63
+ # @param value [Hash] extra headers (values support {{field}})
64
+ # @return [void]
65
+ def headers(value)
66
+ @params[:headers] = value
67
+ end
68
+
69
+ # @param value [String, Hash] plain-text body template or { file: "path" }
70
+ # @return [void]
71
+ def text(value)
72
+ @params[:text] = value
73
+ end
74
+
75
+ # @param value [String, Hash] Markdown body template or { file: "path" }
76
+ # @return [void]
77
+ def markdown_text(value)
78
+ @params[:markdown_text] = value
79
+ end
80
+
81
+ # @param value [String, Hash] HTML body template or { file: "path" }
82
+ # @return [void]
83
+ def html(value)
84
+ @params[:html] = value
85
+ end
86
+ end
87
+ end
88
+ end
@@ -74,6 +74,23 @@ module Inquirex
74
74
  @totals[name.to_sym] || 0
75
75
  end
76
76
 
77
+ # The running narrative of a `:text` accumulator — everything the user was
78
+ # shown and everything they answered, in the order it happened. This is
79
+ # what an LLM `summarize` step reads.
80
+ #
81
+ # @param name [Symbol] text accumulator name (e.g. :transcript)
82
+ # @return [String] empty when nothing has been captured yet
83
+ def text(name)
84
+ @totals[name.to_sym].to_s
85
+ end
86
+
87
+ # Every text accumulator's running narrative, keyed by name.
88
+ #
89
+ # @return [Hash{Symbol => String}]
90
+ def texts
91
+ text_accumulator_names.to_h { |name| [name, text(name)] }
92
+ end
93
+
77
94
  # @return [Node, nil] current step node, or nil if flow is finished
78
95
  def current_step
79
96
  return nil if finished?
@@ -101,9 +118,11 @@ module Inquirex
101
118
  result = @validator.validate(current_step, value)
102
119
  raise Errors::ValidationError, "Validation failed: #{result.errors.join(", ")}" unless result.valid?
103
120
 
121
+ node = current_step
104
122
  @answers[@current_step_id] = value
105
123
  @suggestions.delete(@current_step_id)
106
- apply_accumulations(current_step, value)
124
+ apply_accumulations(node, value)
125
+ capture_transcript(Transcript.answer_entry(node, value))
107
126
  advance_step
108
127
  end
109
128
 
@@ -113,6 +132,8 @@ module Inquirex
113
132
  def advance
114
133
  raise Errors::AlreadyFinishedError, "Flow is already finished" if finished?
115
134
 
135
+ node = current_step
136
+ capture_transcript(Transcript.display_entry(node)) if node.display?
116
137
  advance_step
117
138
  end
118
139
 
@@ -152,6 +173,7 @@ module Inquirex
152
173
  end
153
174
  @skipped << @current_step_id unless @skipped.include?(@current_step_id)
154
175
  @suggestions.delete(@current_step_id)
176
+ capture_transcript(Transcript.skipped_entry(node))
155
177
  advance_step
156
178
  end
157
179
 
@@ -165,14 +187,21 @@ module Inquirex
165
187
 
166
188
  # Merges a hash of { step_id => value } into the top-level answers without
167
189
  # clobbering answers the user has already provided. Used by LLM clarify
168
- # steps to populate downstream answers from free-text extraction so that
169
- # `skip_if not_empty(:id)` rules on later steps will fire.
190
+ # steps to populate downstream answers from free-text extraction; a
191
+ # prefilled question is treated as answered and is never asked again.
170
192
  #
171
193
  # Nil/empty values in the hash are ignored so that "unknown" LLM outputs
172
194
  # don't spuriously satisfy `not_empty` rules.
173
195
  #
174
- # If the engine's current step becomes skippable as a result of the prefill,
175
- # it auto-advances past it.
196
+ # Values for steps with options (enum / multi_enum) are canonicalized via
197
+ # Node#resolve_option matching is against the option's form VALUE, with
198
+ # a case-insensitive fallback and a label fallback ("US citizen or
199
+ # permanent resident" resolves to "us_person"). A value that matches
200
+ # neither value nor label is dropped, so junk never enters the answers.
201
+ #
202
+ # Prefilled answers contribute to accumulators exactly like typed ones,
203
+ # and if the engine's current step becomes skippable as a result of the
204
+ # prefill, it auto-advances past it.
176
205
  #
177
206
  # @param hash [Hash] answers keyed by step id
178
207
  # @return [Hash] the updated answers
@@ -185,13 +214,9 @@ module Inquirex
185
214
 
186
215
  sym = key.to_sym
187
216
  if multi_select_step?(sym)
188
- # Multi-select extraction is a hint, not a fact: the user may have
189
- # more selections in mind than the text revealed. Record it as a
190
- # suggestion so renderers pre-check the choices while the question
191
- # is still asked; skip_if rules see no answer and do not fire.
192
- @suggestions[sym] = Array(value) unless @answers.key?(sym)
217
+ prefill_suggestion(sym, value)
193
218
  else
194
- @answers[sym] = value unless @answers.key?(sym)
219
+ prefill_answer(sym, value)
195
220
  end
196
221
  end
197
222
  skip_if_needed unless finished?
@@ -319,6 +344,30 @@ module Inquirex
319
344
  end
320
345
  end
321
346
 
347
+ # @return [Array<Symbol>] names of the flow's :text accumulators
348
+ def text_accumulator_names
349
+ @definition.accumulators.filter_map { |name, acc| name if acc.text? }
350
+ end
351
+
352
+ # Appends one narrative entry to every text accumulator the flow declares.
353
+ #
354
+ # Called only from #answer, #skip, and #advance — the three points at
355
+ # which the user has actually seen or done something. Steps the engine
356
+ # elides on its own (skip_if, or a question already answered by an
357
+ # extraction) pass through #advance_step instead and are correctly absent
358
+ # from the narrative.
359
+ #
360
+ # @param entry [String, nil] formatted entry, or nil for nothing to record
361
+ # @return [void]
362
+ def capture_transcript(entry)
363
+ return if entry.nil? || entry.empty?
364
+
365
+ text_accumulator_names.each do |name|
366
+ existing = @totals[name].to_s
367
+ @totals[name] = existing.empty? ? entry : "#{existing}\n\n#{entry}"
368
+ end
369
+ end
370
+
322
371
  # The step's default as a concrete value: a Proc default (server-side only,
323
372
  # stripped from JSON) is called with the answers collected so far, exactly
324
373
  # as a renderer pre-filling the field would resolve it.
@@ -367,12 +416,44 @@ module Inquirex
367
416
  @completion_metadata = CompletionMetadata.new(engine: "inquirex", engine_version: VERSION)
368
417
  end
369
418
 
370
- # Auto-skips the current step if its skip_if rule is satisfied.
419
+ # Multi-select extraction is a hint, not a fact: the user may have more
420
+ # selections in mind than the text revealed. Record it as a suggestion so
421
+ # renderers pre-check the choices while the question is still asked;
422
+ # skip_if rules see no answer and do not fire. Each entry is
423
+ # canonicalized against the step's option values; unmatchable entries
424
+ # are dropped, and an all-junk extraction records no suggestion.
425
+ def prefill_suggestion(sym, value)
426
+ return if @answers.key?(sym)
427
+
428
+ node = @definition.step(sym)
429
+ resolved = Array(value).filter_map { |entry| node.resolve_option(entry) }
430
+ @suggestions[sym] = resolved unless resolved.empty?
431
+ end
432
+
433
+ # Single-value extraction is deterministic: the canonicalized value is
434
+ # recorded as the answer (feeding accumulators like a typed answer), and
435
+ # the question will be auto-skipped when reached. Unknown keys — schema
436
+ # fields with no matching step, e.g. a confidence score — are stored
437
+ # verbatim so rules can still read them.
438
+ def prefill_answer(sym, value)
439
+ return if @answers.key?(sym)
440
+
441
+ node = @definition.step_ids.include?(sym) ? @definition.step(sym) : nil
442
+ resolved = node ? node.resolve_option(value) : value
443
+ return if resolved.nil?
444
+
445
+ @answers[sym] = resolved
446
+ apply_accumulations(node, resolved) if node
447
+ end
448
+
449
+ # Auto-skips the current step when its skip_if rule is satisfied, or when
450
+ # it is a collecting step whose answer already exists (prefilled by an
451
+ # LLM extraction) — an answered question is never asked again.
371
452
  def skip_if_needed
372
453
  return if finished?
373
454
 
374
455
  node = @definition.step(@current_step_id)
375
- return unless node.skip?(@answers)
456
+ return unless node.skip?(@answers) || (node.collecting? && @answers.key?(@current_step_id))
376
457
 
377
458
  advance_step
378
459
  end
@@ -32,8 +32,25 @@ module Inquirex
32
32
  # Raised when serializing or deserializing a Definition to/from JSON fails.
33
33
  class SerializationError < Error; end
34
34
 
35
- # Raised when a post-completion action cannot execute structurally,
36
- # e.g. send_email is used without the mail gem installed.
37
- class ActionError < Error; end
35
+ # Raised when DSL source contains anything outside the flow-DSL allowlist,
36
+ # i.e. when it is not safe to `eval`. See Inquirex::SafeSource.
37
+ #
38
+ # Subclasses DefinitionError on purpose: hosts that already rescue that
39
+ # class and render the message keep working, and a rejected payload reads
40
+ # as "invalid DSL" rather than as a crash.
41
+ class UnsafeSourceError < DefinitionError
42
+ # @return [Array<String>] every violation found, most useful first
43
+ attr_reader :violations
44
+
45
+ # @param violations [Array<String>, String] human-readable violation messages
46
+ def initialize(violations)
47
+ @violations = Array(violations)
48
+ super("DSL rejected: #{@violations.join("; ")}")
49
+ end
50
+ end
51
+
52
+ # Raised when a SendEmail cannot build its message structurally,
53
+ # e.g. SendEmail#to_mail is called without the mail gem installed.
54
+ class SendEmailError < Error; end
38
55
  end
39
56
  end
data/lib/inquirex/node.rb CHANGED
@@ -86,6 +86,35 @@ module Inquirex
86
86
  COLLECTING_VERBS.include?(@verb)
87
87
  end
88
88
 
89
+ # Canonicalizes a raw value against this step's options: an exact value
90
+ # match wins, then a case-insensitive value match, then a case-insensitive
91
+ # label match — LLM extractions and humans often answer with the friendly
92
+ # label ("US citizen or permanent resident") when the form value is the
93
+ # canonical key ("us_person"). Matching is always resolved TO the form
94
+ # value, never the label. Steps without options return the value
95
+ # unchanged; an unmatchable value returns nil rather than polluting
96
+ # answers with junk that would satisfy not_empty rules.
97
+ #
98
+ # @example
99
+ # node.options # => ["us_person", "resident"]
100
+ # node.resolve_option("us_person") # => "us_person"
101
+ # node.resolve_option("US_PERSON") # => "us_person"
102
+ # node.resolve_option("US citizen or permanent resident") # => "us_person"
103
+ # node.resolve_option("alien overlord") # => nil
104
+ #
105
+ # @param raw [Object] candidate value (String, Symbol, ...)
106
+ # @return [String, Object, nil] the canonical option value; the raw value
107
+ # unchanged for steps without options; nil when nothing matches
108
+ def resolve_option(raw)
109
+ return raw if @options.nil? || @options.empty?
110
+ return nil if raw.nil?
111
+
112
+ candidate = raw.to_s
113
+ @options.find { |value| value == candidate } ||
114
+ @options.find { |value| value.casecmp?(candidate) } ||
115
+ @option_labels&.find { |_value, label| label.casecmp?(candidate) }&.first
116
+ end
117
+
89
118
  # @return [Boolean] true if this step only displays content (no input)
90
119
  def display?
91
120
  DISPLAY_VERBS.include?(@verb)
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inquirex
4
+ module SafeSource
5
+ # The shape a single allowlisted DSL call may take, built by
6
+ # {Vocabulary.allow} and consumed by {Validator}. Its three members:
7
+ #
8
+ # - `positional` — accepted positional arguments. An Array names each slot's
9
+ # kind in order (`[:symbol]`); `{ repeat: kind, min: n }` accepts any
10
+ # number of arguments of one kind; `{ optional: kind }` accepts zero or one.
11
+ # - `keywords` — `nil` when the call takes no keyword arguments, or a Hash
12
+ # mapping each accepted keyword to its value kind. The key
13
+ # {Vocabulary::ANY_OTHER} sets the kind for every keyword not named
14
+ # explicitly, and is only legitimate when the real method takes `**rest`.
15
+ # - `block` — `:forbidden`; the name of the nested scope whose vocabulary
16
+ # the block's statements are validated against; or `{ optional: scope }`
17
+ # for a call that accepts that block but does not require it, mirroring
18
+ # the `positional` spelling. `send_email` is the optional case: it takes
19
+ # its fields either as keywords or from a block.
20
+ #
21
+ # Value kinds are `:literal`, `:string`, `:symbol`, `:type_name` and `:rule`.
22
+ #
23
+ # @example The spec behind `transition to: :next, if_rule: equals(:a, 1)`
24
+ # CallSpec.new(positional: [],
25
+ # keywords: { to: :symbol, if_rule: :rule, requires_server: :literal },
26
+ # block: :forbidden)
27
+ CallSpec = Data.define(:positional, :keywords, :block) do
28
+ # The scope this call's block opens, with `{ optional: scope }`
29
+ # unwrapped, or nil when the call takes no block at all. Callers that
30
+ # care whether the block is required read {#block} itself.
31
+ #
32
+ # @return [Symbol, nil]
33
+ def block_scope
34
+ case block
35
+ when :forbidden then nil
36
+ when Hash then block[:optional]
37
+ else block
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end