inquirex 0.8.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ae920c980bad7954d0f20a2973daa71998443c206d8e383382a0045404bbfd0c
4
- data.tar.gz: 251356f0766dcab1a469cd2cfcdb71a20f4dcc4b9797f7fbcd5795c4d19533de
3
+ metadata.gz: a5f521bddfb7ad29c91aa0d99b19615ea43462739ba5da7b0a8ba4ce081fc411
4
+ data.tar.gz: 9096cdb056678b643213b17193b7228f984babce96f6bd04f22ce631542438f0
5
5
  SHA512:
6
- metadata.gz: 4d87c1cdd2862ba816f3d593ff22b0ae9b57f2596e293e6974153bfc11cd499084ef3a8dc2fe58d877d073149514137bf9e87b0139e17afd682b497b7fb4c602
7
- data.tar.gz: 71141f1d58413a3c5f03c90585ff4f845608cdd102a13fe3ac330b0c9d6e096b0ee8b393e92bbda9f77793ed6b76425f2750dbab02fbf845159bd19e620d1568
6
+ metadata.gz: 982bfd2e952d710be958ce3a2aba4fc0eccdcacfef4a0d761c6afae09e0a7a4fbfadce93e52ce22c736844cd659da58ec4dbdfb654fb7d6b2988783b885d7f5f
7
+ data.tar.gz: cf62d36c583af4fba6462ca8629187895d14a626ae157df99e5f6a58b0878a1c8b4b97152391dab61767b24449be77d36d0233badd7a17b54c037a3bf85fa1d6
data/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  > - [`inquirex`](https://github.com/inquirex/inquirex): the base gem that defines the graph via DSL and provides most of the backend features
12
12
  > - [`inquirex-llm`](https://github.com/inquirex/inquirex-llm): a tiny gem that extends the DSL by the word `extract` which, given a previous question answered in the form of free text, can use the model of your choice to return structured breakdown of text into answers to questions that might follow, thus shortening the form considerably.
13
13
  > - [`inquirex-tty`](https://github.com/inquirex/inquirex-tty): is the gem that renders the forms on the TUI (Terminal UI). This is also the gem that provides the CLI `inqurex` for performing various tasks such as validating DSL files, converting them from Ruby to JSON and back, and more. It is also the gem where a folder of DSL `examples` can be used to get a feel for how this works.
14
- > - [`inquirex-js`](https://github.com/inquirex/inquirex-js) (`npmjs` module [`@kigster/inquirex-js`](https://www.npmjs.com/package/@kigster/inquirex-js)) is the NPM package that connects web UI with the form definition in JSON format. If LLM is not needed, the entire flow becomes deterministic and collects answers as the user answers your questions, and then POSTS them to the URL of your choice.
14
+ > - [`inquirex-widget`](https://github.com/inquirex/inquirex-js) (`npmjs` module [`inquirex-widget`](https://www.npmjs.com/package/inquirex-widget), formerly `@kigster/inquirex-js`) is the NPM package that connects web UI with the form definition in JSON format. If LLM is not needed, the entire flow becomes deterministic and collects answers as the user answers your questions, and then POSTS them to the URL of your choice.
15
15
  >
16
16
  > For a presentation about these gems and what they do please watch the [RubySF presentation](https://www.youtube.com/watch?v=iaoKW7Ap3_M&t=1s) and you can also [view the slides form the presentation](https://reinvent.one/images/talks/pdfs/2026.inquirex.pdf).
17
17
  >
@@ -51,6 +51,8 @@ This one is the core gem in the Inquirex ecosystem that focuses on:
51
51
 
52
52
  - JSON round-trip serialization for cross-platform clients
53
53
 
54
+ - Declarative completion emails (`send_email` DSL verb — templates the host application delivers)
55
+
54
56
  - A structured `Answers` wrapper and Mermaid graph export (provided by `inquirex-tty` gem's CLI)
55
57
 
56
58
  - In short:
@@ -86,7 +88,7 @@ Then install:
86
88
  bundle install
87
89
  ```
88
90
 
89
- And then, define your form as a Ruby DSL file (see [examples](https://github.com/inquirex/inquirex-tty/tree/main/examples) on Github), and consume it by either the `inquirex-tty` gem on the command line, or on the web via the [@kigster/inqiurex-js](https://www.npmjs.com/package/@kigster/inquirex-js) npmjs package.
91
+ And then, define your form as a Ruby DSL file (see [examples](https://github.com/inquirex/inquirex-tty/tree/main/examples) on Github), and consume it by either the `inquirex-tty` gem on the command line, or on the web via the [inquirex-widget](https://www.npmjs.com/package/inquirex-widget) npmjs package.
90
92
 
91
93
  ## Quick Start
92
94
 
@@ -147,6 +149,7 @@ engine.finished? # => true
147
149
  - `start :step_id` sets the entry step
148
150
  - `meta title:, subtitle:, brand:, theme:` adds optional frontend metadata (see [Theme](#theme-and-branding))
149
151
  - `accumulator :name, type:, default:` declares a running total (see [Accumulators](#accumulators))
152
+ - `send_email [if: rule] do ... end` declares a completion email the host application delivers (see [Completion Emails](#completion-emails-send_email))
150
153
 
151
154
  ### Step verbs
152
155
 
@@ -302,6 +305,41 @@ end
302
305
 
303
306
  A single step can contribute to any number of accumulators.
304
307
 
308
+ ### Text accumulators: the session transcript
309
+
310
+ An accumulator declared `type: :text` accumulates prose instead of a running total, and — unlike every other kind — nothing declares `accumulate` into it. The engine fills it in on its own:
311
+
312
+ ```ruby
313
+ Inquirex.define id: "depreciation-help" do
314
+ accumulator :transcript, type: :text
315
+ start :intro
316
+
317
+ say(:intro) { text "Depreciation spreads an asset's cost over its useful life."; transition to: :asset_kind }
318
+ ask :asset_kind do
319
+ type :enum
320
+ question "What kind of asset?"
321
+ options vehicle: "A vehicle", building: "A building"
322
+ end
323
+ end
324
+ ```
325
+
326
+ After the user advances past `:intro` and answers `:asset_kind`:
327
+
328
+ ```ruby
329
+ engine.text(:transcript)
330
+ # => "Depreciation spreads an asset's cost over its useful life.\n\nQ: What kind of asset?\nA: A vehicle"
331
+ ```
332
+
333
+ Three things it deliberately does:
334
+
335
+ - **Records only what was shown.** A step elided by `skip_if`, or a question auto-skipped because an extraction already answered it, was never on screen and never appears. The narrative is a record of the session, not of the graph.
336
+ - **Renders answers as the user saw them.** Option values resolve to their labels, so it reads `A: Married filing jointly`, not `A: married_filing_jointly`. Booleans read `Yes`/`No`.
337
+ - **Starts at `""`, not `0`.** `default:` is optional and defaults to the type's own zero.
338
+
339
+ This exists because a flow that mostly *tells* the user things — a help flow, an explainer — collects almost no answers, so the answers hash says nothing about what the session contained. The transcript does, which is what makes such a flow summarizable. It is what [`inquirex-llm`](https://github.com/inquirex/inquirex-llm)'s `summarize` verb reads; a flow carrying a `summarize` step gets a `:transcript` accumulator automatically if it has not declared one.
340
+
341
+ Read one with `engine.text(:name)`, or all of them with `engine.texts`.
342
+
305
343
  ### The `price` sugar
306
344
 
307
345
  Since `:price` is the most common use case (lead qualification, tax prep, SaaS quotes), there's a one-liner:
@@ -498,7 +536,7 @@ Guard rails:
498
536
  - `skip` on a **display** step raises `Errors::NonCollectingStepError` (use `advance`)
499
537
  - `skip` after the flow finished raises `Errors::AlreadyFinishedError`
500
538
 
501
- The `skipped` list survives `to_state` / `.from_state` round-trips (string keys from JSON are normalized back to symbols), and `engine.answers_with_metadata` merges it into the answers under `:skipped` — so post-completion actions (webhook payloads, email templates) and API consumers see which values were defaults-by-skip.
539
+ The `skipped` list survives `to_state` / `.from_state` round-trips (string keys from JSON are normalized back to symbols), and `engine.answers_with_metadata` merges it into the answers under `:skipped` — so completion email templates and API consumers see which values were defaults-by-skip.
502
540
 
503
541
  On the wire, step JSON carries `"required": false` (omitted when true, like other defaults):
504
542
 
@@ -535,58 +573,47 @@ engine.after_completion do |eng|
535
573
  end
536
574
  ```
537
575
 
538
- If no hook supplies one, the engine stamps the minimal core version (`engine: "inquirex"`, `engine_version: Inquirex::VERSION`). The metadata persists through `Engine#to_state` / `Engine.from_state`, and `engine.answers_with_metadata` merges it into the answers hash under `:completion_metadata` — which also makes it available to post-completion actions: webhook payloads carry it, and email templates can interpolate `{{completion_metadata.engine}}`.
576
+ If no hook supplies one, the engine stamps the minimal core version (`engine: "inquirex"`, `engine_version: Inquirex::VERSION`). The metadata persists through `Engine#to_state` / `Engine.from_state`, and `engine.answers_with_metadata` merges it into the answers hash under `:completion_metadata` — which also makes it available to completion email templates: `{{completion_metadata.engine}}` interpolates like any other field.
539
577
 
540
- ## Post-Completion Actions
578
+ ## Completion Emails (`send_email`)
541
579
 
542
- After all questions are answered, `action` declarations run server-side with the collected answers. The flagship effect is `send_email`, which **builds** `Mail::Message` objects (the same object ActionMailer wraps) and attaches them to `answers.outbox`**nothing is delivered**; the host application decides how and when to send.
580
+ A flow may declare emails to be sent after all questions are answered. `send_email` is a top-level DSL verb and a **declaration, not an action**: it serializes into the definition JSON under `"send_emails"`, and the host application (e.g. [qualified.at](https://qualified.at)) decides when and how to deliver. This is the only completion declaration the core DSL carries richer post-completion behavior (webhooks, CRM pushes, custom code) belongs to the host application, not to the flow definition.
543
581
 
544
582
  ```ruby
545
583
  Inquirex.define id: "tax-intake-2025" do
546
- allowed_domains "*.agentica.group" # egress allowlist for webhook effects
547
-
548
584
  # ... ask steps ...
549
585
 
550
- action :client_receipt, if: not_empty(:email) do
551
- send_email to: "{{email}}",
552
- from: "forms@agentica.group",
553
- subject: "Thanks {{name}} — we received your intake",
554
- text: <<~TEXT,
555
- Hi {{name}},
556
- We received your answers:
557
-
558
- {{answers_summary}}
559
- TEXT
560
- html: <<~HTML
561
- <p>Hi {{name}},</p>
562
- <img src="https://cdn.agentica.group/logo.png" alt="Logo">
563
- {{answers_summary}}
564
- HTML
565
- end
586
+ # Sent only when the visitor left an email address.
587
+ send_email if: not_empty(:email) do
588
+ to "{{email}}"
589
+ from "forms@agentica.group"
590
+ subject "Thanks {{name}} — we received your intake"
591
+ markdown_text <<~TEXT
592
+ Hi {{name}},
566
593
 
567
- action :admin_alert do
568
- send_email to: "owner@agentica.group", from: "forms@agentica.group",
569
- subject: "New lead: {{name}} <{{email}}>",
570
- html: "{{answers_summary}}"
571
- run { |answers, outbox| Metrics.count(:lead, answers.to_flat_h) }
572
- end
594
+ We received your answers and will reply within one business day.
573
595
 
574
- action :crm_push do
575
- webhook url: "https://hooks.agentica.group/inquirex",
576
- headers: { "X-Api-Key" => "..." }
596
+ {{answers_summary}}
597
+ TEXT
577
598
  end
599
+
600
+ # Inline keyword form works too:
601
+ send_email to: "owner@agentica.group", from: "forms@agentica.group",
602
+ subject: "New lead: {{name}} <{{email}}>",
603
+ html: "{{answers_summary}}"
578
604
  end
579
605
  ```
580
606
 
581
- Execution and delivery:
607
+ Selecting and building on the host:
582
608
 
583
609
  ```ruby
584
- answers = Inquirex::Actions.run(definition, engine.answers)
585
- answers.outbox.messages # => [Mail::Message, ...]
586
- answers.outbox.results # => per-action :ok / :skipped / :failed trail
610
+ definition.send_emails # => [Inquirex::SendEmail, ...] in declaration order
611
+
612
+ applicable = definition.send_emails.select { |email| email.applicable?(engine.answers) }
587
613
 
588
- # In a Rails host:
589
- answers.outbox.each do |message|
614
+ # Optional convenience: build Mail::Message objects (what ActionMailer wraps).
615
+ applicable.each do |declaration|
616
+ message = declaration.to_mail(Inquirex::Answers.new(engine.answers))
590
617
  ActionMailer::Base.wrap_delivery_behavior(message) # adopt Rails delivery config
591
618
  message.deliver
592
619
  end
@@ -594,14 +621,13 @@ end
594
621
 
595
622
  Key semantics:
596
623
 
597
- - **Templating is `{{field}}` interpolation only** dot-notation keys resolved against `Answers#to_flat_h`, deliberately inert (no code execution), so definitions stored in a database render safely. Values interpolated into `html:` bodies are HTML-escaped automatically; `text:` bodies stay verbatim. The built-in `{{answers_summary}}` expands to all collected answers.
598
- - **`if:` gates** reuse the serializable rule AST (`not_empty(:email)`, ...). A false rule records `:skipped` declare no actions (or gate them) when a flow should only save answers.
599
- - **`run { |answers, outbox| ... }`** is the full-Ruby escape hatch; like all lambdas it is stripped from JSON.
600
- - **Failures are isolated**: a raising effect records `:failed` in `outbox.results` and never blocks other actions.
624
+ - **Nothing is delivered by the gem.** A `SendEmail` is data: templated header fields (`to`, `from`, `cc`, `bcc`, `reply_to`, `subject`, `headers`) plus body templates. `#to_mail` is a pure convenience for Ruby hosts.
625
+ - **Templating is `{{field}}` interpolation only** dot-notation keys resolved against `Answers#to_flat_h`, deliberately inert (no code execution), so definitions stored in a database render safely. The built-in `{{answers_summary}}` expands to all collected answers.
626
+ - **Bodies**: `text:` renders verbatim; `html:` HTML-escapes every interpolated value automatically; `markdown_text:` is carried as Markdown on the wire the core gem never renders Markdown to HTML (zero dependencies), hosts render it themselves. At least one body is required. In `#to_mail`, `text` (falling back to `markdown_text`) forms the plain-text part and `html` the HTML part.
627
+ - **`if:` gates** reuse the serializable rule AST (`not_empty(:email)`, ...) and survive the JSON round-trip; hosts filter with `#applicable?(answers_hash)`.
628
+ - Bodies accept `{ file: "path" }`, read **once at definition time** and inlined — a definition rehydrated from JSON never touches the filesystem.
601
629
  - **Images** in HTML bodies must be external URLs; attachments are unsupported.
602
- - The `mail` gem is a soft dependency, needed only when a message is built (Rails hosts already have it via ActionMailer).
603
- - **`webhook url:`** POSTs `{"answers": {...}}` as JSON to a static URL. The URL's host must be covered by `allowed_domains`, declared at the top of the definition so the flow's egress surface is auditable at a glance. The check runs inside `Definition.new` — a JSON definition whose webhook URL was tampered with fails at *rehydration*, before anything executes. Also enforced: https only (plain http just for localhost), no userinfo, no `{{field}}` templates in URLs (the destination must be static), redirects are not followed, and non-2xx responses record `:failed`.
604
- - Effects are extensible: `Inquirex::Actions.register(:save_record, MyEffect)` gives a new verb both DSL and JSON wire support.
630
+ - The `mail` gem is a soft dependency, needed only when `#to_mail` is called (Rails hosts already have it via ActionMailer).
605
631
 
606
632
  ## Serialization
607
633
 
@@ -620,8 +646,7 @@ Serialized structure includes:
620
646
  - Steps and transitions
621
647
  - Rule AST payloads
622
648
  - Widget hints
623
- - Post-completion actions (`actions`) with their rules and effects; `run` blocks are stripped, and an action left with no serializable effects is omitted entirely
624
- - The `allowed_domains` egress allowlist, re-enforced against webhook URLs every time a definition is rehydrated
649
+ - Completion email declarations (`send_emails`) with their `if:` gate rules and body templates (`text`, `markdown_text`, `html`); the key is omitted when no emails are declared
625
650
 
626
651
  Important serialization details:
627
652
 
@@ -631,6 +656,47 @@ Important serialization details:
631
656
  - `required: false` step flag is preserved (omitted when true, the default)
632
657
  - Snake-case theme keys are converted to camelCase on serialization to match the JS widget contract
633
658
 
659
+ ## Loading Flows from Source (`load_dsl`)
660
+
661
+ `Inquirex.load_dsl` evaluates a string of DSL and returns the `Definition`:
662
+
663
+ ```ruby
664
+ definition = Inquirex.load_dsl(File.read("flow.rb"))
665
+ ```
666
+
667
+ An `eval` is fine for a file in your own repository. It is not fine for a string that came from anywhere else — a column a customer edits, an upload, an LLM, a visual builder's "sync" button. Evaluating that unguarded is arbitrary code execution in the process that loads the flow.
668
+
669
+ **Since 0.7.0, `load_dsl` validates before it evaluates.** Source is parsed with Prism and walked against `Inquirex::SafeSource::Vocabulary`, a **default-deny** allowlist of the real DSL: a word nobody declared is a violation, not an oversight. Only source that matches the vocabulary with literal arguments gets past it.
670
+
671
+ ```ruby
672
+ Inquirex.load_dsl(customer.flow_dsl) # validates, then evaluates
673
+ Inquirex.load_dsl(File.read("flow.rb"), unsafe: true) # your own file: skip validation
674
+ ```
675
+
676
+ Audit stored definitions without loading them — the answer is "would `load_dsl` accept this?", which is what you want before a deploy tightens the allowlist:
677
+
678
+ ```ruby
679
+ Qualifier.find_each do |q|
680
+ violations = Inquirex::SafeSource.validate(q.flow_dsl)
681
+ puts "REJECT #{q.id}: #{violations.join("; ")}" if violations.any?
682
+ end
683
+
684
+ Inquirex::SafeSource.safe?(source) # => true / false
685
+ ```
686
+
687
+ Two ceilings bound the work handed to the parser, both adjustable:
688
+
689
+ ```ruby
690
+ Inquirex::SafeSource.max_source_bytes = 256 * 1024 # default 64 KiB
691
+ Inquirex::SafeSource.max_depth = 32 # default 24
692
+ ```
693
+
694
+ ### What this means for existing flows
695
+
696
+ This is the one change most likely to surprise you: **DSL that evaluated in 0.6.x can be rejected in 0.7.0+.** A flow using a construct outside the vocabulary — a top-level constant, a helper method, a computed value — now fails to load rather than silently running. That is the intended behaviour, but it is a real break; run `SafeSource.validate` over your stored flows before upgrading.
697
+
698
+ Downstream gems extend the vocabulary at boot rather than forking it, so `require "inquirex-llm"` teaches it `extract`, `clarify`, and `summarize` automatically.
699
+
634
700
  ## Answers Wrapper
635
701
 
636
702
  `Inquirex::Answers` provides structured answer access:
@@ -674,6 +740,7 @@ Common exceptions under `Inquirex::Errors`:
674
740
  - `ValidationError`
675
741
  - `NonCollectingStepError`
676
742
  - `RequiredStepError`
743
+ - `SendEmailError`
677
744
 
678
745
  ## Development
679
746
 
@@ -5,9 +5,18 @@ module Inquirex
5
5
  # flows accumulate into as the user answers questions. Pure data, serializable
6
6
  # to JSON, evaluated identically on Ruby and JS sides.
7
7
  #
8
+ # A `:text` accumulator is different in kind: nothing declares `accumulate`
9
+ # into it, and no Accumulation ever targets it. The Engine appends to it
10
+ # automatically as the user moves through the flow — every display step's
11
+ # text, and every question with the answer given to it. That running
12
+ # narrative is what an LLM `summarize` step reads, and it is the only way a
13
+ # flow that mostly *tells* the user things (a help or explainer flow) has
14
+ # anything to summarize at all: such a flow collects few answers, so the
15
+ # answers hash alone says nothing about what the user was shown.
16
+ #
8
17
  # @attr_reader name [Symbol] accumulator identifier (e.g. :price)
9
- # @attr_reader type [Symbol] one of Node::TYPES (typically :currency, :integer, :decimal)
10
- # @attr_reader default [Numeric] starting value (default: 0)
18
+ # @attr_reader type [Symbol] one of Node::TYPES (typically :currency, :integer, :decimal, :text)
19
+ # @attr_reader default [Numeric, String] starting value (0, or "" when :text)
11
20
  #
12
21
  # @example Declare a running price total and contribute to it from a step
13
22
  # Inquirex.define do
@@ -17,19 +26,40 @@ module Inquirex
17
26
  # accumulate :price, per_unit: 50
18
27
  # end
19
28
  # end
29
+ #
30
+ # @example Declare a transcript the engine fills in on its own
31
+ # Inquirex.define do
32
+ # accumulator :transcript, type: :text
33
+ # say(:intro) { text "Here is how depreciation works." }
34
+ # end
35
+ # # engine.advance
36
+ # # engine.text(:transcript) # => "Here is how depreciation works."
20
37
  class Accumulator
38
+ # The accumulator type whose running value is appended prose rather than
39
+ # a running total. See the class docs for why it exists.
40
+ TEXT_TYPE = :text
41
+
21
42
  attr_reader :name, :type, :default
22
43
 
23
44
  # @param name [Symbol, String] accumulator identifier
24
45
  # @param type [Symbol, String] value type, one of Node::TYPES
25
- # @param default [Numeric] starting value before any contributions
26
- def initialize(name:, type: :decimal, default: 0)
46
+ # @param default [Numeric, String, nil] starting value before any
47
+ # contributions; nil selects the type's own zero ("" for :text, else 0)
48
+ def initialize(name:, type: :decimal, default: nil)
27
49
  @name = name.to_sym
28
50
  @type = type.to_sym
29
- @default = default
51
+ @default = default.nil? ? zero_value : default
30
52
  freeze
31
53
  end
32
54
 
55
+ # Whether this accumulator accumulates prose rather than a running total.
56
+ # Text accumulators are filled by the Engine, never by an Accumulation.
57
+ #
58
+ # @return [Boolean]
59
+ def text?
60
+ @type == TEXT_TYPE
61
+ end
62
+
33
63
  # Serializes the accumulator to its wire format. The name is omitted —
34
64
  # Definition#to_h keys the accumulators map by name.
35
65
  #
@@ -44,12 +74,24 @@ module Inquirex
44
74
  # @param hash [Hash] type/default attributes (string or symbol keys)
45
75
  # @return [Accumulator]
46
76
  def self.from_h(name, hash)
77
+ # `fetch`, not `||` — a text accumulator's serialized default is "",
78
+ # which `||` would discard in favour of the numeric zero.
79
+ default = hash.fetch("default") { hash.fetch(:default, nil) }
47
80
  new(
48
81
  name: name,
49
82
  type: hash["type"] || hash[:type] || :decimal,
50
- default: hash["default"] || hash[:default] || 0
83
+ default: default
51
84
  )
52
85
  end
86
+
87
+ private
88
+
89
+ # The starting value implied by the type when none was declared.
90
+ #
91
+ # @return [Numeric, String]
92
+ def zero_value
93
+ text? ? "" : 0
94
+ end
53
95
  end
54
96
 
55
97
  # Per-step declaration of how a single answer contributes to one accumulator.
@@ -90,16 +90,6 @@ module Inquirex
90
90
  @data.size
91
91
  end
92
92
 
93
- # Mail::Message objects (and result trail) built by post-completion
94
- # actions. Deliberately excluded from #to_h, #to_flat_h, #to_json and
95
- # #== — the outbox rides alongside the answer data, never inside it.
96
- # Delivery is the host application's responsibility.
97
- #
98
- # @return [Actions::Outbox]
99
- def outbox
100
- @outbox ||= Actions::Outbox.new
101
- end
102
-
103
93
  # Merge another hash or Answers into this one (returns new Answers instance).
104
94
  #
105
95
  # @param other [Hash, Answers]
@@ -19,8 +19,7 @@ module Inquirex
19
19
  :start_step_id,
20
20
  :steps,
21
21
  :accumulators,
22
- :actions,
23
- :allowed_domains
22
+ :send_emails
24
23
 
25
24
  # @param start_step_id [Symbol] id of the initial step
26
25
  # @param nodes [Hash<Symbol, Node>] all steps keyed by id
@@ -28,20 +27,18 @@ module Inquirex
28
27
  # @param version [String] semver
29
28
  # @param meta [Hash] frontend metadata
30
29
  # @param accumulators [Hash<Symbol, Accumulator>] named running totals
31
- # @param actions [Array<Actions::Action>] post-completion actions, in order
32
- # @param allowed_domains [Array<String>] hosts outbound effects (webhook)
33
- # may send answers to; "example.com" exact, "*.example.com" subdomains
30
+ # @param send_emails [Array<SendEmail>] emails the host application builds
31
+ # after completion, in declaration order
34
32
  # @raise [Errors::DefinitionError] if start_step_id is not present in nodes
35
33
  def initialize(start_step_id:, nodes:, id: nil, version: "1.0.0", meta: {},
36
- accumulators: {}, actions: [], allowed_domains: [])
34
+ accumulators: {}, send_emails: [])
37
35
  @id = id
38
36
  @version = version
39
37
  @meta = meta.freeze
40
38
  @start_step_id = start_step_id.to_sym
41
39
  @steps = nodes.freeze
42
40
  @accumulators = accumulators.freeze
43
- @actions = actions.freeze
44
- @allowed_domains = normalize_domains(allowed_domains)
41
+ @send_emails = send_emails.freeze
45
42
  validate!
46
43
  freeze
47
44
  end
@@ -63,25 +60,6 @@ module Inquirex
63
60
  @steps.keys
64
61
  end
65
62
 
66
- # Whether a host is covered by the allowed_domains declaration.
67
- # "example.com" matches that host exactly; "*.example.com" matches any
68
- # subdomain but not the apex. Matching is case-insensitive; an empty
69
- # allowlist allows nothing.
70
- #
71
- # @example With allowed_domains ["*.example.com"]
72
- # definition.allowed_host?("api.example.com") # => true
73
- # definition.allowed_host?("API.EXAMPLE.COM") # => true (case-insensitive)
74
- # definition.allowed_host?("example.com") # => false (wildcard excludes the apex)
75
- #
76
- # @param host [String]
77
- # @return [Boolean]
78
- def allowed_host?(host)
79
- target = host.to_s.downcase
80
- @allowed_domains.any? do |entry|
81
- entry.start_with?("*.") ? target.end_with?(entry[1..]) : target == entry
82
- end
83
- end
84
-
85
63
  # Serializes the definition to a JSON string.
86
64
  # Lambdas (default procs, compute blocks) are silently stripped.
87
65
  #
@@ -98,7 +76,6 @@ module Inquirex
98
76
  hash["id"] = @id if @id
99
77
  hash["version"] = @version
100
78
  hash["meta"] = @meta unless @meta.empty?
101
- hash["allowed_domains"] = @allowed_domains unless @allowed_domains.empty?
102
79
  hash["start"] = @start_step_id.to_s
103
80
  unless @accumulators.empty?
104
81
  hash["accumulators"] = @accumulators.each_with_object({}) do |(name, acc), h|
@@ -106,8 +83,7 @@ module Inquirex
106
83
  end
107
84
  end
108
85
  hash["steps"] = @steps.transform_keys(&:to_s).transform_values(&:to_h)
109
- serializable_actions = @actions.select(&:serializable?)
110
- hash["actions"] = serializable_actions.map(&:to_h) unless serializable_actions.empty?
86
+ hash["send_emails"] = @send_emails.map(&:to_h) unless @send_emails.empty?
111
87
  hash
112
88
  end
113
89
 
@@ -132,8 +108,7 @@ module Inquirex
132
108
  start = hash["start"] || hash[:start]
133
109
  steps_data = hash["steps"] || hash[:steps] || {}
134
110
  acc_data = hash["accumulators"] || hash[:accumulators] || {}
135
- actions_data = hash["actions"] || hash[:actions] || []
136
- domains = hash["allowed_domains"] || hash[:allowed_domains] || []
111
+ emails_data = hash["send_emails"] || hash[:send_emails] || []
137
112
 
138
113
  nodes = steps_data.each_with_object({}) do |(step_id, step_hash), acc|
139
114
  sym_id = step_id.to_sym
@@ -145,7 +120,7 @@ module Inquirex
145
120
  h[sym] = Accumulator.from_h(sym, entry)
146
121
  end
147
122
 
148
- actions = actions_data.map { |entry| Actions::Action.from_h(entry) }
123
+ send_emails = emails_data.map { |entry| SendEmail.from_h(entry) }
149
124
 
150
125
  new(start_step_id: start,
151
126
  nodes:,
@@ -153,32 +128,15 @@ module Inquirex
153
128
  version:,
154
129
  meta:,
155
130
  accumulators:,
156
- actions:,
157
- allowed_domains: domains)
131
+ send_emails:)
158
132
  end
159
133
 
160
134
  private
161
135
 
162
136
  def validate!
163
- unless @steps.key?(@start_step_id)
164
- raise Errors::DefinitionError, "Start step #{@start_step_id.inspect} not found in steps"
165
- end
137
+ return if @steps.key?(@start_step_id)
166
138
 
167
- @actions.each do |action|
168
- action.effects.each { |effect| effect.validate_against(self) }
169
- end
170
- end
171
-
172
- def normalize_domains(domains)
173
- domains.map do |entry|
174
- domain = entry.to_s.strip.downcase
175
- if domain.empty? || domain == "*" || domain.match?(%r{[/\s:@]})
176
- raise Errors::DefinitionError,
177
- "allowed_domains entries must be bare domains like \"example.com\" " \
178
- "or \"*.example.com\", got #{entry.inspect}"
179
- end
180
- domain
181
- end.freeze
139
+ raise Errors::DefinitionError, "Start step #{@start_step_id.inspect} not found in steps"
182
140
  end
183
141
  end
184
142
  end
@@ -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