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.
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi/escape"
4
+
5
+ module Inquirex
6
+ # Renders {{field}} placeholders against collected answers.
7
+ #
8
+ # Placeholders are dot-notation keys resolved via Answers#to_flat_h
9
+ # ("{{email}}", "{{business.count}}"). The syntax is deliberately inert —
10
+ # no code execution — so flow definitions stored in a database can be
11
+ # rendered server-side safely and a visual editor can preview templates
12
+ # client-side. It matches the {{field}} placeholder convention used by
13
+ # inquirex-llm prompts.
14
+ #
15
+ # Rendering modes:
16
+ # - render_text — values inserted verbatim
17
+ # - render_html — every interpolated value is HTML-escaped
18
+ #
19
+ # The built-in {{answers_summary}} placeholder expands to all collected
20
+ # answers: "key: value" lines in text mode, a simple table in HTML mode.
21
+ # Unknown fields render as empty strings; array values join with ", ".
22
+ module Template
23
+ # Matches one {{field}} placeholder; capture 1 is the dot-notation key.
24
+ PLACEHOLDER = /\{\{\s*([\w.]+)\s*\}\}/
25
+ # Reserved placeholder key that expands to a summary of all collected answers.
26
+ SUMMARY_KEY = "answers_summary"
27
+
28
+ module_function
29
+
30
+ # @param string [String] template with {{field}} placeholders
31
+ # @param answers [Answers]
32
+ # @return [String]
33
+ def render_text(string, answers)
34
+ render(string, answers, html: false)
35
+ end
36
+
37
+ # @example Interpolated values are HTML-escaped
38
+ # answers = Inquirex::Answers.new(name: "Bob & Sons")
39
+ # Inquirex::Template.render_html("<p>{{name}}</p>", answers)
40
+ # # => "<p>Bob &amp; Sons</p>"
41
+ #
42
+ # @param string [String] template with {{field}} placeholders
43
+ # @param answers [Answers]
44
+ # @return [String]
45
+ def render_html(string, answers)
46
+ render(string, answers, html: true)
47
+ end
48
+
49
+ # Shared implementation behind render_text and render_html.
50
+ #
51
+ # @param string [String] template with {{field}} placeholders
52
+ # @param answers [Answers]
53
+ # @param html [Boolean] whether to HTML-escape interpolated values
54
+ # @return [String]
55
+ def render(string, answers, html:)
56
+ flat = answers.to_flat_h
57
+ string.gsub(PLACEHOLDER) do
58
+ key = Regexp.last_match(1)
59
+ next(html ? summary_html(flat) : summary_text(flat)) if key == SUMMARY_KEY
60
+
61
+ value = format_value(flat[key])
62
+ html ? CGI.escapeHTML(value) : value
63
+ end
64
+ end
65
+
66
+ # Formats a single answer value for interpolation.
67
+ #
68
+ # @param value [Object] raw answer value (nil renders as "")
69
+ # @return [String] arrays joined with ", ", everything else via #to_s
70
+ def format_value(value)
71
+ value.is_a?(Array) ? value.join(", ") : value.to_s
72
+ end
73
+
74
+ # Expands {{answers_summary}} in text mode.
75
+ #
76
+ # @param flat [Hash{String => Object}] flat answers, dot-notation keys
77
+ # @return [String] one "key: value" line per answer
78
+ def summary_text(flat)
79
+ flat.map { |key, value| "#{key}: #{format_value(value)}" }.join("\n")
80
+ end
81
+
82
+ # Expands {{answers_summary}} in HTML mode.
83
+ #
84
+ # @param flat [Hash{String => Object}] flat answers, dot-notation keys
85
+ # @return [String] a simple HTML table with one row per answer, fully escaped
86
+ def summary_html(flat)
87
+ rows = flat.map do |key, value|
88
+ "<tr><th>#{CGI.escapeHTML(key)}</th><td>#{CGI.escapeHTML(format_value(value))}</td></tr>"
89
+ end
90
+ %(<table class="inquirex-answers">#{rows.join}</table>)
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inquirex
4
+ # Formats what the user actually saw into the prose entries a `:text`
5
+ # accumulator collects.
6
+ #
7
+ # The Engine appends one entry per *interaction* — an answered question, a
8
+ # skipped question, a display step advanced past — and never for a step the
9
+ # engine elided on its own. A step removed by `skip_if`, or auto-skipped
10
+ # because an extraction already answered it, was never on screen, so it must
11
+ # not appear in a narrative that claims to be a record of the session.
12
+ #
13
+ # Entries are plain prose rather than JSON because their only consumer is an
14
+ # LLM prompt: `Q:` / `A:` reads as dialogue to a model, where a serialized
15
+ # answers hash reads as data and produces a summary that sounds like one.
16
+ module Transcript
17
+ # Shown instead of an answer for a question the user declined.
18
+ SKIPPED = "(skipped)"
19
+
20
+ # Shown instead of an answer when a step somehow carries none.
21
+ NO_ANSWER = "(no answer)"
22
+
23
+ class << self
24
+ # The entry for a display step (say/header/btw/warning) the user has
25
+ # just advanced past: its text, verbatim.
26
+ #
27
+ # @param node [Node] the display step
28
+ # @return [String, nil] nil when the step carries no text
29
+ def display_entry(node)
30
+ text = node.text.to_s.strip
31
+ text.empty? ? nil : text
32
+ end
33
+
34
+ # The entry for a question the user answered.
35
+ #
36
+ # @param node [Node] the collecting step
37
+ # @param answer [Object] the value the user submitted
38
+ # @return [String, nil] nil when the step carries no question text
39
+ def answer_entry(node, answer)
40
+ exchange(node, format_answer(answer, node))
41
+ end
42
+
43
+ # The entry for an optional question the user declined.
44
+ #
45
+ # @param node [Node] the collecting step
46
+ # @return [String, nil] nil when the step carries no question text
47
+ def skipped_entry(node)
48
+ exchange(node, SKIPPED)
49
+ end
50
+
51
+ # Renders an answer the way it was shown, not the way it is stored:
52
+ # option values become their labels where the step declares them, so the
53
+ # narrative reads "Married filing jointly" rather than
54
+ # "married_filing_jointly".
55
+ #
56
+ # @param answer [Object] a stored answer value
57
+ # @param node [Node, nil] the step it belongs to, for label lookup
58
+ # @return [String]
59
+ def format_answer(answer, node = nil)
60
+ case answer
61
+ when nil then NO_ANSWER
62
+ when true then "Yes"
63
+ when false then "No"
64
+ when Array then format_array(answer, node)
65
+ else label_for(answer, node)
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ # @return [String, nil]
72
+ def exchange(node, rendered)
73
+ question = node.question.to_s.strip
74
+ return nil if question.empty?
75
+
76
+ "Q: #{question}\nA: #{rendered}"
77
+ end
78
+
79
+ # @return [String]
80
+ def format_array(answer, node)
81
+ return NO_ANSWER if answer.empty?
82
+
83
+ answer.map { |entry| label_for(entry, node) }.join(", ")
84
+ end
85
+
86
+ # @return [String]
87
+ def label_for(value, node)
88
+ labels = node&.option_labels
89
+ labels&.fetch(value.to_s, nil) || value.to_s
90
+ end
91
+ end
92
+ end
93
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Inquirex
4
- VERSION = "0.7.0"
4
+ VERSION = "0.9.4"
5
5
  end
data/lib/inquirex.rb CHANGED
@@ -21,6 +21,7 @@ require_relative "inquirex/widget_registry"
21
21
  require_relative "inquirex/transition"
22
22
  require_relative "inquirex/evaluator"
23
23
  require_relative "inquirex/accumulator"
24
+ require_relative "inquirex/transcript"
24
25
  require_relative "inquirex/node"
25
26
  require_relative "inquirex/definition"
26
27
  require_relative "inquirex/answers"
@@ -30,23 +31,20 @@ require_relative "inquirex/completion_metadata"
30
31
  require_relative "inquirex/validation/adapter"
31
32
  require_relative "inquirex/validation/null_adapter"
32
33
 
33
- # Post-completion actions
34
- require_relative "inquirex/actions"
35
- require_relative "inquirex/actions/template"
36
- require_relative "inquirex/actions/outbox"
37
- require_relative "inquirex/actions/base"
38
- require_relative "inquirex/actions/send_email"
39
- require_relative "inquirex/actions/webhook"
40
- require_relative "inquirex/actions/custom"
41
- require_relative "inquirex/actions/action"
42
- require_relative "inquirex/actions/runner"
34
+ # Completion emails
35
+ require_relative "inquirex/template"
36
+ require_relative "inquirex/send_email"
43
37
 
44
38
  # DSL
45
39
  require_relative "inquirex/dsl"
46
40
  require_relative "inquirex/dsl/rule_helpers"
47
41
  require_relative "inquirex/dsl/step_builder"
48
42
  require_relative "inquirex/dsl/flow_builder"
49
- require_relative "inquirex/dsl/action_builder"
43
+ require_relative "inquirex/dsl/send_email_builder"
44
+
45
+ # Source validation — loads after the DSL builders, whose public methods the
46
+ # allowlist reflects on so it cannot drift from the real vocabulary.
47
+ require_relative "inquirex/safe_source"
50
48
 
51
49
  # Engine
52
50
  require_relative "inquirex/engine/state_serializer"
@@ -108,16 +106,47 @@ module Inquirex
108
106
  # Evaluates a string of DSL code and returns the resulting definition.
109
107
  # Intended for loading flow definitions from files or stored text.
110
108
  #
109
+ # This is an `eval`. Whenever the text comes from anywhere other than your
110
+ # own repository — a database column a customer edits, an upload, an LLM, a
111
+ # visual builder — evaluating it unguarded is arbitrary code execution in
112
+ # the loading process. {SafeSource.validate!} therefore runs **before** the
113
+ # eval unless you opt out; the ordering is the whole point.
114
+ #
115
+ # `unsafe: true` is for source you control as code: a `.rb` file in your own
116
+ # repository, a fixture, the file a CLI was pointed at. It is named `unsafe`
117
+ # rather than `safe: false` so the dangerous call is the conspicuous one.
118
+ #
119
+ # @example Stored, customer-authored DSL (validated)
120
+ # Inquirex.load_dsl(qualifier.flow_dsl)
121
+ #
122
+ # @example Your own file (validation skipped)
123
+ # Inquirex.load_dsl(File.read("flow.rb"), unsafe: true)
124
+ #
111
125
  # @param text [String] Ruby source containing Inquirex.define { ... }
126
+ # @param unsafe [Boolean] skip validation — only for source you authored
127
+ # @param max_bytes [Integer] source size ceiling for validation
128
+ # @param max_depth [Integer] AST nesting ceiling for validation
112
129
  # @return [Definition]
130
+ # @raise [Errors::UnsafeSourceError] when the source leaves the allowlist
113
131
  # @raise [Errors::DefinitionError] on syntax or evaluation errors
114
- def self.load_dsl(text)
115
- # rubocop:disable Security/Eval
116
- eval(text, TOPLEVEL_BINDING.dup, "(dsl)", 1)
117
- # rubocop:enable Security/Eval
118
- rescue SyntaxError => e
119
- raise Errors::DefinitionError, "DSL syntax error: #{e.message}"
120
- rescue StandardError => e
121
- raise Errors::DefinitionError, "DSL evaluation error: #{e.message}"
132
+ def self.load_dsl(text,
133
+ unsafe: false,
134
+ max_bytes: SafeSource.max_source_bytes,
135
+ max_depth: SafeSource.max_depth)
136
+ SafeSource.validate!(text, max_bytes:, max_depth:) unless unsafe
137
+
138
+ begin
139
+ # Deliberate eval: the flow DSL *is* Ruby, so there is nothing else to
140
+ # evaluate it with. Reaching this line means either SafeSource accepted
141
+ # the source against a default-deny allowlist (above), or the caller
142
+ # asserted `unsafe: true` for source it authored itself.
143
+ # rubocop:disable Security/Eval
144
+ eval(text, TOPLEVEL_BINDING.dup, "(dsl)", 1)
145
+ # rubocop:enable Security/Eval
146
+ rescue SyntaxError => e
147
+ raise Errors::DefinitionError, "DSL syntax error: #{e.message}"
148
+ rescue StandardError => e
149
+ raise Errors::DefinitionError, "DSL evaluation error: #{e.message}"
150
+ end
122
151
  end
123
152
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: inquirex
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.9.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Konstantin Gredeskoul
@@ -42,22 +42,13 @@ files:
42
42
  - lib/.DS_Store
43
43
  - lib/inquirex.rb
44
44
  - lib/inquirex/accumulator.rb
45
- - lib/inquirex/actions.rb
46
- - lib/inquirex/actions/action.rb
47
- - lib/inquirex/actions/base.rb
48
- - lib/inquirex/actions/custom.rb
49
- - lib/inquirex/actions/outbox.rb
50
- - lib/inquirex/actions/runner.rb
51
- - lib/inquirex/actions/send_email.rb
52
- - lib/inquirex/actions/template.rb
53
- - lib/inquirex/actions/webhook.rb
54
45
  - lib/inquirex/answers.rb
55
46
  - lib/inquirex/completion_metadata.rb
56
47
  - lib/inquirex/definition.rb
57
48
  - lib/inquirex/dsl.rb
58
- - lib/inquirex/dsl/action_builder.rb
59
49
  - lib/inquirex/dsl/flow_builder.rb
60
50
  - lib/inquirex/dsl/rule_helpers.rb
51
+ - lib/inquirex/dsl/send_email_builder.rb
61
52
  - lib/inquirex/dsl/step_builder.rb
62
53
  - lib/inquirex/engine.rb
63
54
  - lib/inquirex/engine/state_serializer.rb
@@ -73,6 +64,13 @@ files:
73
64
  - lib/inquirex/rules/greater_than.rb
74
65
  - lib/inquirex/rules/less_than.rb
75
66
  - lib/inquirex/rules/not_empty.rb
67
+ - lib/inquirex/safe_source.rb
68
+ - lib/inquirex/safe_source/call_spec.rb
69
+ - lib/inquirex/safe_source/validator.rb
70
+ - lib/inquirex/safe_source/vocabulary.rb
71
+ - lib/inquirex/send_email.rb
72
+ - lib/inquirex/template.rb
73
+ - lib/inquirex/transcript.rb
76
74
  - lib/inquirex/transition.rb
77
75
  - lib/inquirex/validation/adapter.rb
78
76
  - lib/inquirex/validation/null_adapter.rb
@@ -102,8 +100,9 @@ required_rubygems_version: !ruby/object:Gem::Requirement
102
100
  - !ruby/object:Gem::Version
103
101
  version: '0'
104
102
  requirements: []
105
- rubygems_version: 4.0.16
103
+ rubygems_version: 4.0.17
106
104
  specification_version: 4
107
105
  summary: A declarative, rules-driven questionnaire engine for building conditionally-branching
108
- intake forms, qualification wizards, and surveys in pure Ruby.
106
+ intake forms, qualification wizards, and surveys in pure Ruby, with additional plugins
107
+ providing LLM/AI support. See inquirex-llm.
109
108
  test_files: []
@@ -1,68 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Inquirex
4
- module Actions
5
- # A named post-completion unit: an optional rule gating execution and an
6
- # ordered list of effects. Declared with the DSL word `action`:
7
- #
8
- # action :client_receipt, if: not_empty(:email) do
9
- # send_email to: "{{email}}", subject: "Thanks {{name}}!", text: "..."
10
- # end
11
- #
12
- # Rules reuse the same serializable AST as transitions, so conditions
13
- # survive the JSON round-trip. Non-serializable effects (run blocks) are
14
- # stripped on serialization; an action left with no serializable effects
15
- # is omitted from JSON entirely.
16
- class Action
17
- attr_reader :id, :rule, :effects
18
-
19
- # @param id [Symbol] action identifier
20
- # @param effects [Array<Actions::Base>] executed in declaration order
21
- # @param rule [Rules::Base, nil] gate — action runs only when true
22
- def initialize(id:, effects:, rule: nil)
23
- @id = id.to_sym
24
- @effects = effects.freeze
25
- @rule = rule
26
- freeze
27
- end
28
-
29
- # @param answers_hash [Hash] step_id => value context for rule evaluation
30
- # @return [Boolean]
31
- def applicable?(answers_hash)
32
- @rule.nil? || @rule.evaluate(answers_hash)
33
- end
34
-
35
- # @return [Boolean] whether anything survives JSON serialization
36
- def serializable?
37
- @effects.any?(&:serializable?)
38
- end
39
-
40
- # @return [Hash] wire format; run blocks are stripped
41
- def to_h
42
- hash = { "id" => @id.to_s }
43
- hash["if"] = @rule.to_h if @rule
44
- hash["effects"] = @effects.select(&:serializable?).map(&:to_h)
45
- hash
46
- end
47
-
48
- # @param hash [Hash] string or symbol keys
49
- # @return [Action]
50
- def self.from_h(hash)
51
- id = hash["id"] || hash[:id]
52
- rule_data = hash["if"] || hash[:if]
53
- effects_data = hash["effects"] || hash[:effects] || []
54
-
55
- effects = effects_data.map do |effect_hash|
56
- type = effect_hash["type"] || effect_hash[:type]
57
- Actions.lookup(type).from_h(effect_hash)
58
- end
59
-
60
- new(
61
- id: id.to_sym,
62
- effects: effects,
63
- rule: rule_data ? Rules::Base.from_h(rule_data) : nil
64
- )
65
- end
66
- end
67
- end
68
- end
@@ -1,41 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Inquirex
4
- module Actions
5
- # Abstract base for action effects — the executable units inside an
6
- # `action` block. Subclasses implement #call and, when serializable,
7
- # #to_h / .from_h following the same round-trip pattern as Rules::Base.
8
- #
9
- # Effects that wrap Ruby procs (Actions::Custom) return false from
10
- # #serializable? and are stripped from JSON, consistent with how
11
- # lambdas are handled everywhere else in Inquirex.
12
- class Base
13
- # Executes the effect. Email-building effects append Mail::Message
14
- # objects to the outbox; custom effects may do anything server-side.
15
- #
16
- # @param answers [Answers] completed answers
17
- # @param outbox [Outbox] collector for built messages
18
- # @return [void]
19
- def call(answers, outbox)
20
- raise NotImplementedError, "#{self.class}#call must be implemented"
21
- end
22
-
23
- # @return [Boolean] whether this effect survives JSON serialization
24
- def serializable? = true
25
-
26
- # Hook for effects that must be checked against the definition carrying
27
- # them (e.g. Webhook vs allowed_domains). Runs inside
28
- # Definition#validate!, which both DSL-built and JSON-rehydrated
29
- # definitions pass through — so violations fail at load time.
30
- #
31
- # @param _definition [Definition]
32
- # @raise [Errors::DefinitionError] on violation
33
- def validate_against(_definition); end
34
-
35
- # @return [Hash]
36
- def to_h
37
- raise NotImplementedError, "#{self.class}#to_h must be implemented"
38
- end
39
- end
40
- end
41
- end
@@ -1,31 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Inquirex
4
- module Actions
5
- # Escape-hatch effect wrapping an arbitrary Ruby block, declared in the
6
- # DSL as `run { |answers, outbox| ... }`. Full language power — build a
7
- # Mail::Message by hand with outbox.add_message, call a service, record
8
- # metrics — at the cost of serialization: like every lambda in Inquirex,
9
- # the block is stripped from JSON and exists only in Ruby-authored
10
- # definitions.
11
- class Custom < Base
12
- # @param block [Proc] receives (answers, outbox)
13
- def initialize(block)
14
- super()
15
- @block = block
16
- freeze
17
- end
18
-
19
- # Invokes the wrapped block with the collected answers and the outbox.
20
- #
21
- # @param answers [Answers] completed answers
22
- # @param outbox [Outbox] collector for messages the block may build
23
- # @return [Object] whatever the block returns (recorded, not interpreted)
24
- def call(answers, outbox)
25
- @block.call(answers, outbox)
26
- end
27
-
28
- def serializable? = false
29
- end
30
- end
31
- end
@@ -1,57 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Inquirex
4
- module Actions
5
- # Collected output of running a definition's actions: the Mail::Message
6
- # objects built by send_email effects, plus a per-execution result trail.
7
- #
8
- # The outbox is attached to Answers#outbox and is intentionally excluded
9
- # from Answers serialization — mail objects never leak into persisted
10
- # answer data. Delivery is the host application's job:
11
- #
12
- # answers.outbox.each do |message|
13
- # ActionMailer::Base.wrap_delivery_behavior(message)
14
- # message.deliver
15
- # end
16
- class Outbox
17
- include Enumerable
18
-
19
- # One entry per effect execution (or per skipped action).
20
- # status is :ok, :skipped (action rule was false), or :failed.
21
- Result = Data.define(:action_id, :status, :error)
22
-
23
- attr_reader :messages, :results
24
-
25
- def initialize
26
- @messages = []
27
- @results = []
28
- end
29
-
30
- # @param mail [Mail::Message]
31
- # @return [Mail::Message]
32
- def add_message(mail)
33
- @messages << mail
34
- mail
35
- end
36
-
37
- # @param action_id [Symbol]
38
- # @param status [Symbol] :ok, :skipped, or :failed
39
- # @param error [StandardError, nil]
40
- def record(action_id, status, error: nil)
41
- @results << Result.new(action_id:, status:, error:)
42
- end
43
-
44
- # Iterates over built Mail::Message objects.
45
- def each(&) = @messages.each(&)
46
-
47
- # @return [Integer] number of built messages
48
- def size = @messages.size
49
-
50
- # @return [Boolean]
51
- def empty? = @messages.empty?
52
-
53
- # @return [Array<Result>] executions that raised
54
- def failures = @results.select { |result| result.status == :failed }
55
- end
56
- end
57
- end
@@ -1,52 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Inquirex
4
- module Actions
5
- # Executes a definition's actions against completed answers, in
6
- # declaration order, populating Answers#outbox with built messages and a
7
- # result trail.
8
- #
9
- # Deliberately separate from the Engine: in the cross-site architecture
10
- # the frontend collects answers and a different server process
11
- # post-processes them, so the runner is a pure function of
12
- # (definition, answers) — which also makes rebuilding messages inside a
13
- # background job trivial.
14
- #
15
- # A failing effect records a :failed result and never aborts the other
16
- # actions; the host inspects outbox.failures and decides what to do.
17
- class Runner
18
- # @param definition [Definition]
19
- def initialize(definition)
20
- @definition = definition
21
- end
22
-
23
- # @param answers [Answers, Hash] completed answers (a Hash is wrapped)
24
- # @return [Answers] the (possibly wrapped) answers with #outbox populated
25
- def call(answers)
26
- answers = Answers.new(answers) unless answers.is_a?(Answers)
27
- context = answers.to_h
28
-
29
- @definition.actions.each do |action|
30
- unless action.applicable?(context)
31
- answers.outbox.record(action.id, :skipped)
32
- next
33
- end
34
- execute(action, answers)
35
- end
36
-
37
- answers
38
- end
39
-
40
- private
41
-
42
- def execute(action, answers)
43
- action.effects.each do |effect|
44
- effect.call(answers, answers.outbox)
45
- answers.outbox.record(action.id, :ok)
46
- rescue StandardError => e
47
- answers.outbox.record(action.id, :failed, error: e)
48
- end
49
- end
50
- end
51
- end
52
- end