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 +4 -4
- data/README.md +115 -48
- data/lib/inquirex/accumulator.rb +48 -6
- data/lib/inquirex/answers.rb +0 -10
- data/lib/inquirex/definition.rb +11 -53
- data/lib/inquirex/dsl/flow_builder.rb +52 -45
- data/lib/inquirex/dsl/send_email_builder.rb +88 -0
- data/lib/inquirex/engine.rb +94 -13
- data/lib/inquirex/errors.rb +20 -3
- data/lib/inquirex/node.rb +29 -0
- data/lib/inquirex/safe_source/call_spec.rb +42 -0
- data/lib/inquirex/safe_source/validator.rb +559 -0
- data/lib/inquirex/safe_source/vocabulary.rb +322 -0
- data/lib/inquirex/safe_source.rb +111 -0
- data/lib/inquirex/send_email.rb +206 -0
- data/lib/inquirex/template.rb +93 -0
- data/lib/inquirex/transcript.rb +93 -0
- data/lib/inquirex/version.rb +1 -1
- data/lib/inquirex.rb +48 -19
- metadata +12 -13
- data/lib/inquirex/actions/action.rb +0 -68
- data/lib/inquirex/actions/base.rb +0 -41
- data/lib/inquirex/actions/custom.rb +0 -31
- data/lib/inquirex/actions/outbox.rb +0 -57
- data/lib/inquirex/actions/runner.rb +0 -52
- data/lib/inquirex/actions/send_email.rb +0 -174
- data/lib/inquirex/actions/template.rb +0 -95
- data/lib/inquirex/actions/webhook.rb +0 -139
- data/lib/inquirex/actions.rb +0 -57
- data/lib/inquirex/dsl/action_builder.rb +0 -53
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a5f521bddfb7ad29c91aa0d99b19615ea43462739ba5da7b0a8ba4ce081fc411
|
|
4
|
+
data.tar.gz: 9096cdb056678b643213b17193b7228f984babce96f6bd04f22ce631542438f0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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-
|
|
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 [
|
|
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
|
|
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
|
|
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
|
-
##
|
|
578
|
+
## Completion Emails (`send_email`)
|
|
541
579
|
|
|
542
|
-
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
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
|
-
|
|
575
|
-
|
|
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
|
-
|
|
607
|
+
Selecting and building on the host:
|
|
582
608
|
|
|
583
609
|
```ruby
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
610
|
+
definition.send_emails # => [Inquirex::SendEmail, ...] in declaration order
|
|
611
|
+
|
|
612
|
+
applicable = definition.send_emails.select { |email| email.applicable?(engine.answers) }
|
|
587
613
|
|
|
588
|
-
#
|
|
589
|
-
|
|
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
|
-
- **
|
|
598
|
-
-
|
|
599
|
-
-
|
|
600
|
-
- **
|
|
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
|
|
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
|
-
-
|
|
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
|
|
data/lib/inquirex/accumulator.rb
CHANGED
|
@@ -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 (
|
|
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
|
|
26
|
-
|
|
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:
|
|
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.
|
data/lib/inquirex/answers.rb
CHANGED
|
@@ -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]
|
data/lib/inquirex/definition.rb
CHANGED
|
@@ -19,8 +19,7 @@ module Inquirex
|
|
|
19
19
|
:start_step_id,
|
|
20
20
|
:steps,
|
|
21
21
|
:accumulators,
|
|
22
|
-
:
|
|
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
|
|
32
|
-
#
|
|
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: {},
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
allowed_domains: domains)
|
|
131
|
+
send_emails:)
|
|
158
132
|
end
|
|
159
133
|
|
|
160
134
|
private
|
|
161
135
|
|
|
162
136
|
def validate!
|
|
163
|
-
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
#
|
|
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
|
|
40
|
-
|
|
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
|
|
120
|
-
#
|
|
121
|
-
#
|
|
122
|
-
# the
|
|
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
|
-
#
|
|
125
|
-
#
|
|
126
|
-
#
|
|
127
|
-
#
|
|
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
|
|
132
|
-
#
|
|
133
|
-
# @yield block evaluated in
|
|
134
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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:
|
|
158
|
-
nodes:
|
|
159
|
-
id:
|
|
160
|
-
version:
|
|
161
|
-
meta:
|
|
162
|
-
accumulators:
|
|
163
|
-
|
|
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
|
|