inquirex 0.2.0 → 0.3.1
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 +261 -13
- data/docs/badges/coverage_badge.svg +21 -0
- data/lib/inquirex/accumulator.rb +145 -0
- data/lib/inquirex/definition.rb +16 -3
- data/lib/inquirex/dsl/flow_builder.rb +46 -4
- data/lib/inquirex/dsl/step_builder.rb +83 -8
- data/lib/inquirex/engine/state_serializer.rb +2 -1
- data/lib/inquirex/engine.rb +54 -2
- data/lib/inquirex/node.rb +61 -5
- data/lib/inquirex/version.rb +1 -1
- data/lib/inquirex/widget_hint.rb +35 -0
- data/lib/inquirex/widget_registry.rb +71 -0
- data/lib/inquirex.rb +5 -0
- metadata +11 -9
- data/exe/inquirex +0 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5457c529512019dbfcb838eb1a607d933d85cad7a2edc926bd0049eca271f2f0
|
|
4
|
+
data.tar.gz: f561ac674e088508c2bb33726016660dc5a29cbbeb085e17ec84de8833c45859
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: be941ae3435a8e114dce7234477afb5d29e2624f7ddf8ed07fe8118d23b031390dd4c59610251f8419469765e4c6db5bc5413522b9f1befcc5e218e95b295513
|
|
7
|
+
data.tar.gz: 831876215c0ee5cd29d7bf72549d9a430633b3dafe6f154e813df2891381bc9d269ddddbcef117a6b86697c32ae4708fdf30faf0a2d8f18e259b3f933f76c1a2
|
data/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
[](https://github.com/inquirex/inquirex/actions/workflows/main.yml) 
|
|
2
|
+
|
|
1
3
|
# Inquirex
|
|
2
4
|
|
|
3
5
|
`inquirex` is a pure Ruby, declarative, rules-driven questionnaire engine for building conditional intake forms, qualification wizards, and branching surveys.
|
|
@@ -6,6 +8,8 @@ It is the core gem in the Inquirex ecosystem and focuses on:
|
|
|
6
8
|
|
|
7
9
|
- A conversational DSL (`ask`, `say`, `header`, `btw`, `warning`, `confirm`)
|
|
8
10
|
- A serializable AST rule system (`contains`, `equals`, `greater_than`, `less_than`, `not_empty`, `all`, `any`)
|
|
11
|
+
- Framework-agnostic widget rendering hints (`widget` DSL verb, `WidgetHint`, `WidgetRegistry`)
|
|
12
|
+
- Named **accumulators** for running totals (pricing, complexity scoring, credit scoring, lead qualification)
|
|
9
13
|
- An immutable flow definition graph
|
|
10
14
|
- A runtime engine for stateful step traversal
|
|
11
15
|
- JSON round-trip serialization for cross-platform clients
|
|
@@ -13,10 +17,10 @@ It is the core gem in the Inquirex ecosystem and focuses on:
|
|
|
13
17
|
|
|
14
18
|
## Status
|
|
15
19
|
|
|
16
|
-
- Version: `0.
|
|
20
|
+
- Version: `0.2.0`
|
|
17
21
|
- Ruby: `>= 4.0.0` (project currently uses `4.0.2`)
|
|
18
|
-
- Test suite: `
|
|
19
|
-
- Coverage: ~`
|
|
22
|
+
- Test suite: `220 examples, 0 failures`
|
|
23
|
+
- Coverage: ~`94%` line coverage
|
|
20
24
|
|
|
21
25
|
## Why Inquirex
|
|
22
26
|
|
|
@@ -56,6 +60,8 @@ definition = Inquirex.define id: "tax-intake-2025", version: "1.0.0" do
|
|
|
56
60
|
type :enum
|
|
57
61
|
question "What is your filing status?"
|
|
58
62
|
options single: "Single", married_jointly: "Married Filing Jointly"
|
|
63
|
+
widget target: :desktop, type: :radio_group, columns: 2
|
|
64
|
+
widget target: :mobile, type: :dropdown
|
|
59
65
|
transition to: :dependents
|
|
60
66
|
end
|
|
61
67
|
|
|
@@ -98,7 +104,8 @@ engine.finished? # => true
|
|
|
98
104
|
### Flow-level methods
|
|
99
105
|
|
|
100
106
|
- `start :step_id` sets the entry step
|
|
101
|
-
- `meta title:, subtitle:, brand:` adds optional frontend metadata
|
|
107
|
+
- `meta title:, subtitle:, brand:, theme:` adds optional frontend metadata (see [Theme](#theme-and-branding))
|
|
108
|
+
- `accumulator :name, type:, default:` declares a running total (see [Accumulators](#accumulators))
|
|
102
109
|
|
|
103
110
|
### Step verbs
|
|
104
111
|
|
|
@@ -128,6 +135,212 @@ engine.finished? # => true
|
|
|
128
135
|
- `skip_if rule`
|
|
129
136
|
- `transition to: :next_step, if_rule: rule, requires_server: false`
|
|
130
137
|
- `compute { |answers| ... }` (accepted by the DSL as a server-side hook; currently omitted from runtime JSON)
|
|
138
|
+
- `widget target: :desktop, type: :radio_group, columns: 2` (rendering hint for frontend adapters)
|
|
139
|
+
- `accumulate :name, lookup:|per_selection:|per_unit:|flat:` (contribution to a named running total; see [Accumulators](#accumulators))
|
|
140
|
+
- `price ...` (sugar for `accumulate :price, ...`)
|
|
141
|
+
|
|
142
|
+
## Widget Rendering Hints
|
|
143
|
+
|
|
144
|
+
Every collecting step can carry framework-agnostic rendering hints via the `widget` DSL verb. Frontend adapters (JS widget, TTY, Rails) use these to pick the right UI control.
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
ask :priority do
|
|
148
|
+
type :enum
|
|
149
|
+
question "How urgent is this?"
|
|
150
|
+
options low: "Low", medium: "Medium", high: "High"
|
|
151
|
+
widget target: :desktop, type: :radio_group, columns: 3
|
|
152
|
+
widget target: :mobile, type: :dropdown
|
|
153
|
+
widget target: :tty, type: :select
|
|
154
|
+
transition to: :next_step
|
|
155
|
+
end
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
When no explicit `widget` is set, `WidgetRegistry` fills in sensible defaults per data type:
|
|
159
|
+
|
|
160
|
+
| Data Type | Desktop | Mobile | TTY |
|
|
161
|
+
|-----------|---------|--------|-----|
|
|
162
|
+
| `:enum` | `radio_group` | `dropdown` | `select` |
|
|
163
|
+
| `:multi_enum` | `checkbox_group` | `checkbox_group` | `multi_select` |
|
|
164
|
+
| `:boolean` | `toggle` | `yes_no_buttons` | `yes_no` |
|
|
165
|
+
| `:string` | `text_input` | `text_input` | `text_input` |
|
|
166
|
+
| `:text` | `textarea` | `textarea` | `multiline` |
|
|
167
|
+
| `:integer` | `number_input` | `number_input` | `number_input` |
|
|
168
|
+
| `:currency` | `currency_input` | `currency_input` | `number_input` |
|
|
169
|
+
| `:date` | `date_picker` | `date_picker` | `text_input` |
|
|
170
|
+
|
|
171
|
+
Display verbs (`say`, `header`, `btw`, `warning`) have no widget hints.
|
|
172
|
+
|
|
173
|
+
Widget hints are included in JSON serialization under a `"widget"` key, keyed by target:
|
|
174
|
+
|
|
175
|
+
```json
|
|
176
|
+
"widget": {
|
|
177
|
+
"desktop": { "type": "radio_group", "columns": 3 },
|
|
178
|
+
"mobile": { "type": "dropdown" },
|
|
179
|
+
"tty": { "type": "select" }
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### Accessing Hints at Runtime
|
|
184
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
step = definition.step(:priority)
|
|
187
|
+
step.widget_hint_for(target: :desktop) # explicit hint or nil
|
|
188
|
+
step.effective_widget_hint_for(target: :desktop) # explicit hint or registry default
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
> **Note:** Widget hints were previously in a separate `inquirex-ui` gem. As of v0.2.0 they are part of core, since every frontend adapter needs them.
|
|
192
|
+
|
|
193
|
+
## Accumulators
|
|
194
|
+
|
|
195
|
+
Accumulators are **named running totals** that a flow maintains as the user answers questions. The canonical use case is **pricing** — totalling the cost of a tax return, a SaaS quote, or an insurance premium — but the same primitive generalizes to **complexity scoring**, **credit scoring**, **lead qualification scores**, **risk scores**, or any other numeric tally.
|
|
196
|
+
|
|
197
|
+
Like rules, accumulator declarations are **pure data**. They serialize to JSON and evaluate identically on the Ruby server and in the JS widget — no lambdas, no server round-trips.
|
|
198
|
+
|
|
199
|
+
### Declaring accumulators
|
|
200
|
+
|
|
201
|
+
Each flow declares one or more accumulators with a name, a type, and a starting value:
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
Inquirex.define id: "tax-pricing-2025" do
|
|
205
|
+
accumulator :price, type: :currency, default: 0
|
|
206
|
+
accumulator :complexity, type: :integer, default: 0
|
|
207
|
+
# ...
|
|
208
|
+
end
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### Contributing to an accumulator from a step
|
|
212
|
+
|
|
213
|
+
Use the `accumulate` verb inside any `ask`/`confirm` step. Exactly one **shape** key must be provided:
|
|
214
|
+
|
|
215
|
+
| Shape | Fits | Semantics |
|
|
216
|
+
|-------|------|-----------|
|
|
217
|
+
| `lookup: { ... }` | `:enum` | Adds the amount mapped to the chosen option value |
|
|
218
|
+
| `per_selection: { ... }` | `:multi_enum` | Sums the amounts for every selected option |
|
|
219
|
+
| `per_unit: N` | `:integer`, `:decimal` | Multiplies the numeric answer by `N` |
|
|
220
|
+
| `flat: N` | any type | Adds `N` when the step has a truthy, non-empty answer |
|
|
221
|
+
|
|
222
|
+
```ruby
|
|
223
|
+
ask :filing_status do
|
|
224
|
+
type :enum
|
|
225
|
+
question "Filing status?"
|
|
226
|
+
options single: "Single", mfj: "Married Filing Jointly", hoh: "Head of Household"
|
|
227
|
+
accumulate :price, lookup: { single: 200, mfj: 400, hoh: 300 }
|
|
228
|
+
accumulate :complexity, lookup: { mfj: 1 }
|
|
229
|
+
transition to: :dependents
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
ask :dependents do
|
|
233
|
+
type :integer
|
|
234
|
+
question "How many dependents?"
|
|
235
|
+
default 0
|
|
236
|
+
accumulate :price, per_unit: 25 # $25 per dependent
|
|
237
|
+
transition to: :schedules
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
ask :schedules do
|
|
241
|
+
type :multi_enum
|
|
242
|
+
question "Which schedules apply?"
|
|
243
|
+
options c: "Schedule C (Business)",
|
|
244
|
+
e: "Schedule E (Rental)",
|
|
245
|
+
d: "Schedule D (Capital Gains)"
|
|
246
|
+
accumulate :price, per_selection: { c: 150, e: 75, d: 50 }
|
|
247
|
+
accumulate :complexity, per_selection: { c: 2, e: 1, d: 1 }
|
|
248
|
+
transition to: :done
|
|
249
|
+
end
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
A single step can contribute to any number of accumulators.
|
|
253
|
+
|
|
254
|
+
### The `price` sugar
|
|
255
|
+
|
|
256
|
+
Since `:price` is the most common use case (lead qualification, tax prep, SaaS quotes), there's a one-liner:
|
|
257
|
+
|
|
258
|
+
```ruby
|
|
259
|
+
price single: 200, mfj: 400, hoh: 300 # => accumulate :price, lookup: { ... }
|
|
260
|
+
price per_unit: 25 # => accumulate :price, per_unit: 25
|
|
261
|
+
price per_selection: { c: 150, e: 75 } # => accumulate :price, per_selection: { ... }
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
If you pass a plain option-value → amount hash (no shape key), `price` treats it as a `lookup`.
|
|
265
|
+
|
|
266
|
+
### Reading totals at runtime
|
|
267
|
+
|
|
268
|
+
The engine maintains running totals as each answer comes in:
|
|
269
|
+
|
|
270
|
+
```ruby
|
|
271
|
+
engine = Inquirex::Engine.new(definition)
|
|
272
|
+
engine.answer("mfj") # filing_status: +$400, +1 complexity
|
|
273
|
+
engine.answer(3) # dependents: +$75
|
|
274
|
+
engine.answer(%w[c e]) # schedules: +$225, +3 complexity
|
|
275
|
+
|
|
276
|
+
engine.total(:price) # => 700.0
|
|
277
|
+
engine.total(:complexity) # => 4
|
|
278
|
+
engine.totals # => { price: 700.0, complexity: 4 }
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
`#to_state` includes `totals:` so persisted sessions resume with the correct running total.
|
|
282
|
+
|
|
283
|
+
### JSON wire format
|
|
284
|
+
|
|
285
|
+
Accumulators serialize predictably, keeping the contract with `inquirex-js` explicit:
|
|
286
|
+
|
|
287
|
+
```json
|
|
288
|
+
{
|
|
289
|
+
"accumulators": {
|
|
290
|
+
"price": { "type": "currency", "default": 0 },
|
|
291
|
+
"complexity": { "type": "integer", "default": 0 }
|
|
292
|
+
},
|
|
293
|
+
"steps": {
|
|
294
|
+
"filing_status": {
|
|
295
|
+
"verb": "ask",
|
|
296
|
+
"type": "enum",
|
|
297
|
+
"accumulate": {
|
|
298
|
+
"price": { "lookup": { "single": 200, "mfj": 400, "hoh": 300 } },
|
|
299
|
+
"complexity": { "lookup": { "mfj": 1 } }
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
"dependents": {
|
|
303
|
+
"verb": "ask",
|
|
304
|
+
"type": "integer",
|
|
305
|
+
"accumulate": { "price": { "per_unit": 25 } }
|
|
306
|
+
},
|
|
307
|
+
"schedules": {
|
|
308
|
+
"verb": "ask",
|
|
309
|
+
"type": "multi_enum",
|
|
310
|
+
"accumulate": {
|
|
311
|
+
"price": { "per_selection": { "c": 150, "e": 75, "d": 50 } },
|
|
312
|
+
"complexity": { "per_selection": { "c": 2, "e": 1, "d": 1 } }
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
The `inquirex-js` widget reads this verbatim and reproduces the same totals client-side.
|
|
320
|
+
|
|
321
|
+
## Theme and Branding
|
|
322
|
+
|
|
323
|
+
The flow's `meta` hash carries optional branding and theme overrides for the JS widget. Identity (name, logo) goes in `brand:`; colors, fonts, and radii go in `theme:`.
|
|
324
|
+
|
|
325
|
+
```ruby
|
|
326
|
+
meta title: "Tax Preparation Intake",
|
|
327
|
+
subtitle: "Let's understand your situation",
|
|
328
|
+
brand: { name: "Agentica", logo: "https://cdn.example.com/logo.png" },
|
|
329
|
+
theme: {
|
|
330
|
+
brand: "#2563eb",
|
|
331
|
+
on_brand: "#ffffff",
|
|
332
|
+
background: "#0b1020",
|
|
333
|
+
surface: "#111827",
|
|
334
|
+
text: "#f9fafb",
|
|
335
|
+
text_muted: "#94a3b8",
|
|
336
|
+
border: "#1f2937",
|
|
337
|
+
radius: "18px",
|
|
338
|
+
font: "Inter, system-ui, sans-serif",
|
|
339
|
+
header_font: "Inter, system-ui, sans-serif"
|
|
340
|
+
}
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
Snake-case keys (`on_brand`, `text_muted`, `header_font`) are idiomatic Ruby; they're automatically translated to the camelCase names (`onBrand`, `textMuted`, `headerFont`) the JS widget expects on the wire. Each theme key maps 1:1 to a CSS custom property on the widget's shadow root.
|
|
131
344
|
|
|
132
345
|
## Rule System (AST, JSON-serializable)
|
|
133
346
|
|
|
@@ -159,13 +372,32 @@ transition to: :complex_path,
|
|
|
159
372
|
- `current_step`
|
|
160
373
|
- `answers` (raw hash)
|
|
161
374
|
- `history` (visited step IDs)
|
|
375
|
+
- `totals` (running totals per accumulator — see [Accumulators](#accumulators))
|
|
162
376
|
|
|
163
377
|
Behavior:
|
|
164
378
|
|
|
165
379
|
- Use `answer(value)` on collecting steps
|
|
166
380
|
- Use `advance` on display steps
|
|
167
381
|
- Use `finished?` to detect completion
|
|
168
|
-
- Use `
|
|
382
|
+
- Use `total(:price)` / `totals` to read running totals
|
|
383
|
+
- Use `to_state` / `.from_state` for persistence/resume (totals included)
|
|
384
|
+
- Use `prefill!(hash)` to merge externally-supplied answers into the state,
|
|
385
|
+
e.g. fields extracted by an LLM from a free-text answer (see
|
|
386
|
+
[inquirex-llm](#extension-gems)). Existing answers are preserved; `nil`
|
|
387
|
+
and empty values are ignored so they don't spuriously satisfy
|
|
388
|
+
`not_empty` rules. The engine auto-advances past any newly-skippable step.
|
|
389
|
+
|
|
390
|
+
```ruby
|
|
391
|
+
engine = Inquirex::Engine.new(definition)
|
|
392
|
+
engine.answer("I'm MFJ with two kids in California.") # free-text :describe
|
|
393
|
+
|
|
394
|
+
# Step is now :extracted (a clarify node); adapter returns a Hash.
|
|
395
|
+
result = adapter.call(engine.current_step, engine.answers)
|
|
396
|
+
engine.answer(result) # store under the clarify step's id
|
|
397
|
+
engine.prefill!(result) # splat into top-level answers
|
|
398
|
+
# Downstream :filing_status, :dependents, :state are now auto-skipped by
|
|
399
|
+
# `skip_if not_empty(:filing_status)` etc.
|
|
400
|
+
```
|
|
169
401
|
|
|
170
402
|
### Validation Adapter
|
|
171
403
|
|
|
@@ -192,14 +424,18 @@ restored = Inquirex::Definition.from_json(json)
|
|
|
192
424
|
Serialized structure includes:
|
|
193
425
|
|
|
194
426
|
- Flow metadata (`id`, `version`, `meta`, `start`)
|
|
427
|
+
- Branding and theme (`meta.brand`, `meta.theme`)
|
|
428
|
+
- Accumulator declarations (`accumulators`) and per-step contributions (`accumulate`)
|
|
195
429
|
- Steps and transitions
|
|
196
430
|
- Rule AST payloads
|
|
431
|
+
- Widget hints
|
|
197
432
|
|
|
198
433
|
Important serialization details:
|
|
199
434
|
|
|
200
|
-
- Rule objects serialize and deserialize cleanly
|
|
435
|
+
- Rule objects and accumulator shapes serialize and deserialize cleanly
|
|
201
436
|
- Proc/lambda defaults are stripped from JSON
|
|
202
437
|
- `requires_server: true` transition flag is preserved
|
|
438
|
+
- Snake-case theme keys are converted to camelCase on serialization to match the JS widget contract
|
|
203
439
|
|
|
204
440
|
## Answers Wrapper
|
|
205
441
|
|
|
@@ -274,15 +510,27 @@ just lint-fix
|
|
|
274
510
|
just ci
|
|
275
511
|
```
|
|
276
512
|
|
|
277
|
-
##
|
|
513
|
+
## Extension Gems
|
|
514
|
+
|
|
515
|
+
The core gem is designed to be extended by optional gems that inject new DSL verbs at `require` time:
|
|
516
|
+
|
|
517
|
+
```ruby
|
|
518
|
+
require "inquirex" # core DSL, rules, engine, widget hints
|
|
519
|
+
require "inquirex-llm" # adds: clarify, describe, summarize, detour
|
|
520
|
+
|
|
521
|
+
Inquirex.define do # one entry point, all verbs available
|
|
522
|
+
# ...
|
|
523
|
+
end
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
### Ecosystem
|
|
278
527
|
|
|
279
|
-
|
|
528
|
+
- **`inquirex-llm`** -- LLM-powered verbs (`clarify`, `describe`, `summarize`, `detour`) for server-side AI processing
|
|
529
|
+
- **`inquirex-tty`** -- terminal adapter using TTY Toolkit
|
|
530
|
+
- **`inquirex-js`** -- embeddable browser widget (chat-style)
|
|
531
|
+
- **`inquirex-rails`** -- Rails Engine for persistence, API, and asset serving
|
|
280
532
|
|
|
281
|
-
|
|
282
|
-
- `inquirex-tty` for terminal interaction
|
|
283
|
-
- `inquirex-js` for embeddable web widget runtime
|
|
284
|
-
- `inquirex-llm` for server-side LLM verbs
|
|
285
|
-
- `inquirex-rails` for persistence/API integration
|
|
533
|
+
> **Note:** `inquirex-ui` has been merged into core as of v0.2.0. Widget hints (`widget` DSL verb, `WidgetHint`, `WidgetRegistry`) are now built in.
|
|
286
534
|
|
|
287
535
|
## License
|
|
288
536
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="99" height="20">
|
|
3
|
+
<linearGradient id="b" x2="0" y2="100%">
|
|
4
|
+
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
|
5
|
+
<stop offset="1" stop-opacity=".1"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
<mask id="a">
|
|
8
|
+
<rect width="99" height="20" rx="3" fill="#fff"/>
|
|
9
|
+
</mask>
|
|
10
|
+
<g mask="url(#a)">
|
|
11
|
+
<path fill="#555" d="M0 0h63v20H0z"/>
|
|
12
|
+
<path fill="#4c1" d="M63 0h36v20H63z"/>
|
|
13
|
+
<path fill="url(#b)" d="M0 0h99v20H0z"/>
|
|
14
|
+
</g>
|
|
15
|
+
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
|
|
16
|
+
<text x="31.5" y="15" fill="#010101" fill-opacity=".3">coverage</text>
|
|
17
|
+
<text x="31.5" y="14">coverage</text>
|
|
18
|
+
<text x="80" y="15" fill="#010101" fill-opacity=".3">95%</text>
|
|
19
|
+
<text x="80" y="14">95%</text>
|
|
20
|
+
</g>
|
|
21
|
+
</svg>
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
# Declares a named running total (e.g. :price, :complexity, :credit_score) that
|
|
5
|
+
# flows accumulate into as the user answers questions. Pure data, serializable
|
|
6
|
+
# to JSON, evaluated identically on Ruby and JS sides.
|
|
7
|
+
#
|
|
8
|
+
# @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)
|
|
11
|
+
class Accumulator
|
|
12
|
+
attr_reader :name, :type, :default
|
|
13
|
+
|
|
14
|
+
def initialize(name:, type: :decimal, default: 0)
|
|
15
|
+
@name = name.to_sym
|
|
16
|
+
@type = type.to_sym
|
|
17
|
+
@default = default
|
|
18
|
+
freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def to_h
|
|
22
|
+
{ "type" => @type.to_s, "default" => @default }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.from_h(name, hash)
|
|
26
|
+
new(
|
|
27
|
+
name: name,
|
|
28
|
+
type: hash["type"] || hash[:type] || :decimal,
|
|
29
|
+
default: hash["default"] || hash[:default] || 0
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Per-step declaration of how a single answer contributes to one accumulator.
|
|
35
|
+
# A Node may carry zero or more Accumulation entries, one per target accumulator.
|
|
36
|
+
#
|
|
37
|
+
# Supported shapes (exactly one must be set):
|
|
38
|
+
# lookup: Hash of answer_value => amount (for :enum)
|
|
39
|
+
# per_selection: Hash of option_value => amount (for :multi_enum, summed)
|
|
40
|
+
# per_unit: Numeric rate multiplied by the answer (for numeric types)
|
|
41
|
+
# flat: Numeric amount added if the step has any answer
|
|
42
|
+
#
|
|
43
|
+
# @attr_reader target [Symbol] accumulator name to contribute to (e.g. :price)
|
|
44
|
+
# @attr_reader shape [Symbol] one of :lookup, :per_selection, :per_unit, :flat
|
|
45
|
+
# @attr_reader payload [Object] shape-specific data (Hash or Numeric)
|
|
46
|
+
class Accumulation
|
|
47
|
+
SHAPES = %i[lookup per_selection per_unit flat].freeze
|
|
48
|
+
|
|
49
|
+
attr_reader :target, :shape, :payload
|
|
50
|
+
|
|
51
|
+
def initialize(target:, shape:, payload:)
|
|
52
|
+
@target = target.to_sym
|
|
53
|
+
@shape = shape.to_sym
|
|
54
|
+
raise Errors::DefinitionError, "Unknown accumulator shape: #{shape.inspect}" unless SHAPES.include?(@shape)
|
|
55
|
+
|
|
56
|
+
@payload = self.class.send(:normalize_payload, @shape, payload).freeze
|
|
57
|
+
freeze
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Computes this accumulation's contribution given the step's answer.
|
|
61
|
+
# Returns 0 when the answer is absent or does not match the shape.
|
|
62
|
+
#
|
|
63
|
+
# @param answer [Object] the user's answer for the owning step
|
|
64
|
+
# @return [Numeric]
|
|
65
|
+
def contribution(answer)
|
|
66
|
+
return 0 if answer.nil?
|
|
67
|
+
|
|
68
|
+
case @shape
|
|
69
|
+
when :lookup then lookup_amount(answer)
|
|
70
|
+
when :per_selection then selection_amount(answer)
|
|
71
|
+
when :per_unit then unit_amount(answer)
|
|
72
|
+
when :flat then flat_amount(answer)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def to_h
|
|
77
|
+
{ @shape.to_s => serialize_payload }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Parses a single-key Hash like `{ "lookup" => {...} }` into an Accumulation.
|
|
81
|
+
#
|
|
82
|
+
# @param target [Symbol, String] accumulator name
|
|
83
|
+
# @param hash [Hash] serialized shape (string or symbol keys)
|
|
84
|
+
# @return [Accumulation]
|
|
85
|
+
def self.from_h(target, hash)
|
|
86
|
+
pair = hash.to_a.find { |k, _| SHAPES.include?(k.to_sym) }
|
|
87
|
+
raise Errors::SerializationError, "Invalid accumulation entry: #{hash.inspect}" unless pair
|
|
88
|
+
|
|
89
|
+
shape_key, payload = pair
|
|
90
|
+
new(target: target, shape: shape_key.to_sym, payload: payload)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.normalize_payload(shape, payload)
|
|
94
|
+
case shape
|
|
95
|
+
when :lookup, :per_selection
|
|
96
|
+
(payload || {}).each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
|
|
97
|
+
else
|
|
98
|
+
payload
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
private_class_method :normalize_payload
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def serialize_payload
|
|
106
|
+
case @shape
|
|
107
|
+
when :lookup, :per_selection
|
|
108
|
+
@payload.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
|
|
109
|
+
else
|
|
110
|
+
@payload
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def lookup_amount(answer)
|
|
115
|
+
@payload[answer.to_s] || 0
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def selection_amount(answer)
|
|
119
|
+
return 0 unless answer.is_a?(Array)
|
|
120
|
+
|
|
121
|
+
answer.sum { |selected| @payload[selected.to_s] || 0 }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def unit_amount(answer)
|
|
125
|
+
numeric = numeric_for(answer)
|
|
126
|
+
return 0 if numeric.nil?
|
|
127
|
+
|
|
128
|
+
numeric * @payload
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def flat_amount(answer)
|
|
132
|
+
return 0 if answer == false
|
|
133
|
+
return 0 if answer.respond_to?(:empty?) && answer.empty?
|
|
134
|
+
|
|
135
|
+
@payload
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def numeric_for(value)
|
|
139
|
+
case value
|
|
140
|
+
when Numeric then value
|
|
141
|
+
when String then Float(value, exception: false)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
data/lib/inquirex/definition.rb
CHANGED
|
@@ -13,20 +13,22 @@ module Inquirex
|
|
|
13
13
|
# @attr_reader start_step_id [Symbol] id of the first step in the flow
|
|
14
14
|
# @attr_reader steps [Hash<Symbol, Node>] frozen map of step id => node
|
|
15
15
|
class Definition
|
|
16
|
-
attr_reader :id, :version, :meta, :start_step_id, :steps
|
|
16
|
+
attr_reader :id, :version, :meta, :start_step_id, :steps, :accumulators
|
|
17
17
|
|
|
18
18
|
# @param start_step_id [Symbol] id of the initial step
|
|
19
19
|
# @param nodes [Hash<Symbol, Node>] all steps keyed by id
|
|
20
20
|
# @param id [String, nil] flow identifier
|
|
21
21
|
# @param version [String] semver
|
|
22
22
|
# @param meta [Hash] frontend metadata
|
|
23
|
+
# @param accumulators [Hash<Symbol, Accumulator>] named running totals
|
|
23
24
|
# @raise [Errors::DefinitionError] if start_step_id is not present in nodes
|
|
24
|
-
def initialize(start_step_id:, nodes:, id: nil, version: "1.0.0", meta: {})
|
|
25
|
+
def initialize(start_step_id:, nodes:, id: nil, version: "1.0.0", meta: {}, accumulators: {})
|
|
25
26
|
@id = id
|
|
26
27
|
@version = version
|
|
27
28
|
@meta = meta.freeze
|
|
28
29
|
@start_step_id = start_step_id.to_sym
|
|
29
30
|
@steps = nodes.freeze
|
|
31
|
+
@accumulators = accumulators.freeze
|
|
30
32
|
validate!
|
|
31
33
|
freeze
|
|
32
34
|
end
|
|
@@ -65,6 +67,11 @@ module Inquirex
|
|
|
65
67
|
hash["version"] = @version
|
|
66
68
|
hash["meta"] = @meta unless @meta.empty?
|
|
67
69
|
hash["start"] = @start_step_id.to_s
|
|
70
|
+
unless @accumulators.empty?
|
|
71
|
+
hash["accumulators"] = @accumulators.each_with_object({}) do |(name, acc), h|
|
|
72
|
+
h[name.to_s] = acc.to_h
|
|
73
|
+
end
|
|
74
|
+
end
|
|
68
75
|
hash["steps"] = @steps.transform_keys(&:to_s).transform_values(&:to_h)
|
|
69
76
|
hash
|
|
70
77
|
end
|
|
@@ -89,13 +96,19 @@ module Inquirex
|
|
|
89
96
|
meta = hash["meta"] || hash[:meta] || {}
|
|
90
97
|
start = hash["start"] || hash[:start]
|
|
91
98
|
steps_data = hash["steps"] || hash[:steps] || {}
|
|
99
|
+
acc_data = hash["accumulators"] || hash[:accumulators] || {}
|
|
92
100
|
|
|
93
101
|
nodes = steps_data.each_with_object({}) do |(step_id, step_hash), acc|
|
|
94
102
|
sym_id = step_id.to_sym
|
|
95
103
|
acc[sym_id] = Node.from_h(sym_id, step_hash)
|
|
96
104
|
end
|
|
97
105
|
|
|
98
|
-
|
|
106
|
+
accumulators = acc_data.each_with_object({}) do |(name, entry), h|
|
|
107
|
+
sym = name.to_sym
|
|
108
|
+
h[sym] = Accumulator.from_h(sym, entry)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
new(start_step_id: start, nodes:, id:, version:, meta:, accumulators:)
|
|
99
112
|
end
|
|
100
113
|
|
|
101
114
|
private
|
|
@@ -15,6 +15,19 @@ module Inquirex
|
|
|
15
15
|
@start_step_id = nil
|
|
16
16
|
@nodes = {}
|
|
17
17
|
@meta = {}
|
|
18
|
+
@accumulators = {}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Declares a named running total the flow accumulates into as answers come in.
|
|
22
|
+
# The `:price` accumulator is the common lead-qualification use case; others
|
|
23
|
+
# (e.g. :complexity, :credit_score) work identically.
|
|
24
|
+
#
|
|
25
|
+
# @param name [Symbol] e.g. :price
|
|
26
|
+
# @param type [Symbol] one of Node::TYPES (default :currency-ish: :decimal)
|
|
27
|
+
# @param default [Numeric] starting value (default: 0)
|
|
28
|
+
def accumulator(name, type: :decimal, default: 0)
|
|
29
|
+
sym = name.to_sym
|
|
30
|
+
@accumulators[sym] = Accumulator.new(name: sym, type:, default:)
|
|
18
31
|
end
|
|
19
32
|
|
|
20
33
|
# Sets the entry step id for the flow.
|
|
@@ -24,15 +37,28 @@ module Inquirex
|
|
|
24
37
|
@start_step_id = step_id
|
|
25
38
|
end
|
|
26
39
|
|
|
27
|
-
# Sets frontend metadata: title, subtitle, and
|
|
40
|
+
# Sets frontend metadata: title, subtitle, brand, and theme.
|
|
28
41
|
#
|
|
29
42
|
# @param title [String, nil]
|
|
30
43
|
# @param subtitle [String, nil]
|
|
31
|
-
# @param brand [Hash, nil]
|
|
32
|
-
|
|
44
|
+
# @param brand [Hash, nil] identity — `{ name: "Acme", logo: "https://..." }`.
|
|
45
|
+
# Colors and fonts belong in `theme:`, not here.
|
|
46
|
+
# @param theme [Hash, nil] visual overrides consumed by the JS widget.
|
|
47
|
+
# Every key maps 1:1 to a CSS custom property on the widget's shadow root.
|
|
48
|
+
# Supported keys: :brand, :on_brand (or :onBrand), :background, :surface,
|
|
49
|
+
# :text, :text_muted (or :textMuted), :border, :radius, :font,
|
|
50
|
+
# :header_font (or :headerFont).
|
|
51
|
+
#
|
|
52
|
+
# @example
|
|
53
|
+
# meta title: "Tax Intake",
|
|
54
|
+
# subtitle: "Let's get started",
|
|
55
|
+
# brand: { name: "Agentica", logo: "https://cdn.example.com/logo.png" },
|
|
56
|
+
# theme: { brand: "#2563eb", radius: "18px", font: "Inter, system-ui" }
|
|
57
|
+
def meta(title: nil, subtitle: nil, brand: nil, theme: nil)
|
|
33
58
|
@meta[:title] = title if title
|
|
34
59
|
@meta[:subtitle] = subtitle if subtitle
|
|
35
60
|
@meta[:brand] = brand if brand
|
|
61
|
+
@meta[:theme] = normalize_theme(theme) if theme
|
|
36
62
|
end
|
|
37
63
|
|
|
38
64
|
# Defines a question step that collects typed input from the user.
|
|
@@ -91,12 +117,28 @@ module Inquirex
|
|
|
91
117
|
nodes: @nodes,
|
|
92
118
|
id: @flow_id,
|
|
93
119
|
version: @flow_version,
|
|
94
|
-
meta: @meta
|
|
120
|
+
meta: @meta,
|
|
121
|
+
accumulators: @accumulators
|
|
95
122
|
)
|
|
96
123
|
end
|
|
97
124
|
|
|
125
|
+
# Maps snake_case theme keys (idiomatic in Ruby) to the camelCase names
|
|
126
|
+
# the JS widget (inquirex-js ThemeOverrides) expects on the wire.
|
|
127
|
+
THEME_KEY_ALIASES = {
|
|
128
|
+
on_brand: :onBrand,
|
|
129
|
+
text_muted: :textMuted,
|
|
130
|
+
header_font: :headerFont
|
|
131
|
+
}.freeze
|
|
132
|
+
|
|
98
133
|
private
|
|
99
134
|
|
|
135
|
+
def normalize_theme(theme)
|
|
136
|
+
theme.each_with_object({}) do |(key, value), acc|
|
|
137
|
+
sym = key.to_sym
|
|
138
|
+
acc[THEME_KEY_ALIASES.fetch(sym, sym)] = value
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
100
142
|
def add_step(id, verb, &block)
|
|
101
143
|
builder = StepBuilder.new(verb)
|
|
102
144
|
builder.instance_eval(&block) if block
|
|
@@ -18,6 +18,41 @@ module Inquirex
|
|
|
18
18
|
@skip_if = nil
|
|
19
19
|
@default = nil
|
|
20
20
|
@compute = nil
|
|
21
|
+
@widget_hints = {}
|
|
22
|
+
@accumulations = []
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Declares how this step's answer contributes to a named accumulator.
|
|
26
|
+
# Exactly one shape key should be provided:
|
|
27
|
+
# lookup: Hash of answer_value => amount (for :enum)
|
|
28
|
+
# per_selection: Hash of option_value => amount (for :multi_enum)
|
|
29
|
+
# per_unit: Numeric rate (multiplied by the numeric answer)
|
|
30
|
+
# flat: Numeric (added when the step has any answer)
|
|
31
|
+
#
|
|
32
|
+
# @example
|
|
33
|
+
# accumulate :price, lookup: { single: 200, mfj: 400 }
|
|
34
|
+
# accumulate :price, per_unit: 25
|
|
35
|
+
# accumulate :price, per_selection: { c: 150, e: 75 }
|
|
36
|
+
# accumulate :complexity, flat: 1
|
|
37
|
+
def accumulate(target, lookup: nil, per_selection: nil, per_unit: nil, flat: nil)
|
|
38
|
+
shape, payload = pick_accumulator_shape(lookup:, per_selection:, per_unit:, flat:)
|
|
39
|
+
@accumulations << Accumulation.new(target:, shape:, payload:)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Sugar for the common `:price` accumulator. Accepts either a shape keyword
|
|
43
|
+
# (`lookup:`, `per_selection:`, `per_unit:`, `flat:`) or — when given plain
|
|
44
|
+
# option=>amount keys — treats it as a `lookup`. So both work:
|
|
45
|
+
#
|
|
46
|
+
# price single: 200, mfj: 400 # => lookup
|
|
47
|
+
# price per_unit: 25 # => per_unit
|
|
48
|
+
# price lookup: { single: 200, ... } # => lookup (explicit)
|
|
49
|
+
def price(**kwargs)
|
|
50
|
+
shape_keys = %i[lookup per_selection per_unit flat]
|
|
51
|
+
if kwargs.keys.intersect?(shape_keys)
|
|
52
|
+
accumulate(:price, **kwargs.slice(*shape_keys))
|
|
53
|
+
else
|
|
54
|
+
accumulate(:price, lookup: kwargs)
|
|
55
|
+
end
|
|
21
56
|
end
|
|
22
57
|
|
|
23
58
|
# Sets the input data type for :ask steps.
|
|
@@ -45,6 +80,21 @@ module Inquirex
|
|
|
45
80
|
@options = list
|
|
46
81
|
end
|
|
47
82
|
|
|
83
|
+
# Sets a rendering hint for the given target context.
|
|
84
|
+
# Recognized targets: :desktop, :mobile, :tty (and any future targets).
|
|
85
|
+
#
|
|
86
|
+
# @param type [Symbol] widget type (e.g. :radio_group, :dropdown)
|
|
87
|
+
# @param target [Symbol] rendering context (default: :desktop)
|
|
88
|
+
# @param opts [Hash] widget-specific options (e.g. columns: 2)
|
|
89
|
+
#
|
|
90
|
+
# @example
|
|
91
|
+
# widget target: :desktop, type: :radio_group, columns: 2
|
|
92
|
+
# widget target: :mobile, type: :dropdown
|
|
93
|
+
# widget target: :tty, type: :select
|
|
94
|
+
def widget(type:, target: :desktop, **opts)
|
|
95
|
+
@widget_hints[target.to_sym] = WidgetHint.new(type:, options: opts)
|
|
96
|
+
end
|
|
97
|
+
|
|
48
98
|
# Adds a conditional transition. First matching transition wins.
|
|
49
99
|
#
|
|
50
100
|
# @param to [Symbol] target step id
|
|
@@ -85,25 +135,50 @@ module Inquirex
|
|
|
85
135
|
def build(id)
|
|
86
136
|
Node.new(
|
|
87
137
|
id:,
|
|
88
|
-
verb:
|
|
89
|
-
type:
|
|
90
|
-
question:
|
|
91
|
-
text:
|
|
92
|
-
options:
|
|
93
|
-
transitions:
|
|
94
|
-
skip_if:
|
|
95
|
-
default:
|
|
138
|
+
verb: @verb,
|
|
139
|
+
type: resolve_type,
|
|
140
|
+
question: @question,
|
|
141
|
+
text: @text,
|
|
142
|
+
options: @options,
|
|
143
|
+
transitions: @transitions,
|
|
144
|
+
skip_if: @skip_if,
|
|
145
|
+
default: @default,
|
|
146
|
+
widget_hints: resolve_widget_hints,
|
|
147
|
+
accumulations: @accumulations
|
|
96
148
|
)
|
|
97
149
|
end
|
|
98
150
|
|
|
99
151
|
private
|
|
100
152
|
|
|
153
|
+
def pick_accumulator_shape(lookup:, per_selection:, per_unit:, flat:)
|
|
154
|
+
provided = { lookup:, per_selection:, per_unit:, flat: }.compact
|
|
155
|
+
if provided.size != 1
|
|
156
|
+
raise Errors::DefinitionError,
|
|
157
|
+
"accumulate requires exactly one of :lookup, :per_selection, :per_unit, :flat (got #{provided.keys})"
|
|
158
|
+
end
|
|
159
|
+
provided.first
|
|
160
|
+
end
|
|
161
|
+
|
|
101
162
|
def resolve_type
|
|
102
163
|
# :confirm is sugar for :ask with type :boolean
|
|
103
164
|
return :boolean if @verb == :confirm && @type.nil?
|
|
104
165
|
|
|
105
166
|
@type
|
|
106
167
|
end
|
|
168
|
+
|
|
169
|
+
# Fills in registry defaults for targets not explicitly set (collecting steps only).
|
|
170
|
+
# Display verbs get nil widget_hints.
|
|
171
|
+
def resolve_widget_hints
|
|
172
|
+
effective_type = resolve_type
|
|
173
|
+
return nil if effective_type.nil? && @widget_hints.empty?
|
|
174
|
+
|
|
175
|
+
hints = @widget_hints.dup
|
|
176
|
+
%i[desktop mobile].each do |target|
|
|
177
|
+
hints[target] ||= WidgetRegistry.default_hint_for(effective_type, context: target)
|
|
178
|
+
end
|
|
179
|
+
hints.compact!
|
|
180
|
+
hints.empty? ? nil : hints
|
|
181
|
+
end
|
|
107
182
|
end
|
|
108
183
|
end
|
|
109
184
|
end
|
|
@@ -8,7 +8,8 @@ module Inquirex
|
|
|
8
8
|
SYMBOLIZERS = {
|
|
9
9
|
current_step_id: ->(v) { v&.to_sym },
|
|
10
10
|
history: ->(v) { Array(v).map { |e| e&.to_sym } },
|
|
11
|
-
answers: ->(v) { symbolize_answers(v) }
|
|
11
|
+
answers: ->(v) { symbolize_answers(v) },
|
|
12
|
+
totals: ->(v) { symbolize_answers(v) }
|
|
12
13
|
}.freeze
|
|
13
14
|
|
|
14
15
|
# Normalizes a state hash so step ids and history entries are symbols.
|
data/lib/inquirex/engine.rb
CHANGED
|
@@ -10,7 +10,7 @@ module Inquirex
|
|
|
10
10
|
# Validates each answer via an optional Validation::Adapter, then advances using
|
|
11
11
|
# node transitions. Skips steps whose skip_if rule evaluates to true.
|
|
12
12
|
class Engine
|
|
13
|
-
attr_reader :definition, :answers, :history, :current_step_id
|
|
13
|
+
attr_reader :definition, :answers, :history, :current_step_id, :totals
|
|
14
14
|
|
|
15
15
|
# @param definition [Definition] the flow to run
|
|
16
16
|
# @param validator [Validation::Adapter] optional (default: NullAdapter)
|
|
@@ -20,10 +20,19 @@ module Inquirex
|
|
|
20
20
|
@history = []
|
|
21
21
|
@current_step_id = definition.start_step_id
|
|
22
22
|
@validator = validator
|
|
23
|
+
@totals = init_totals
|
|
23
24
|
@history << @current_step_id
|
|
24
25
|
skip_display_steps_if_needed
|
|
25
26
|
end
|
|
26
27
|
|
|
28
|
+
# Convenience accessor for a single accumulator's running total.
|
|
29
|
+
#
|
|
30
|
+
# @param name [Symbol] accumulator name (e.g. :price)
|
|
31
|
+
# @return [Numeric]
|
|
32
|
+
def total(name)
|
|
33
|
+
@totals[name.to_sym] || 0
|
|
34
|
+
end
|
|
35
|
+
|
|
27
36
|
# @return [Node, nil] current step node, or nil if flow is finished
|
|
28
37
|
def current_step
|
|
29
38
|
return nil if finished?
|
|
@@ -52,6 +61,7 @@ module Inquirex
|
|
|
52
61
|
raise Errors::ValidationError, "Validation failed: #{result.errors.join(", ")}" unless result.valid?
|
|
53
62
|
|
|
54
63
|
@answers[@current_step_id] = value
|
|
64
|
+
apply_accumulations(current_step, value)
|
|
55
65
|
advance_step
|
|
56
66
|
end
|
|
57
67
|
|
|
@@ -64,6 +74,33 @@ module Inquirex
|
|
|
64
74
|
advance_step
|
|
65
75
|
end
|
|
66
76
|
|
|
77
|
+
# Merges a hash of { step_id => value } into the top-level answers without
|
|
78
|
+
# clobbering answers the user has already provided. Used by LLM clarify
|
|
79
|
+
# steps to populate downstream answers from free-text extraction so that
|
|
80
|
+
# `skip_if not_empty(:id)` rules on later steps will fire.
|
|
81
|
+
#
|
|
82
|
+
# Nil/empty values in the hash are ignored so that "unknown" LLM outputs
|
|
83
|
+
# don't spuriously satisfy `not_empty` rules.
|
|
84
|
+
#
|
|
85
|
+
# If the engine's current step becomes skippable as a result of the prefill,
|
|
86
|
+
# it auto-advances past it.
|
|
87
|
+
#
|
|
88
|
+
# @param hash [Hash] answers keyed by step id
|
|
89
|
+
# @return [Hash] the updated answers
|
|
90
|
+
def prefill!(hash)
|
|
91
|
+
return @answers unless hash.is_a?(Hash)
|
|
92
|
+
|
|
93
|
+
hash.each do |key, value|
|
|
94
|
+
next if value.nil?
|
|
95
|
+
next if value.respond_to?(:empty?) && value.empty?
|
|
96
|
+
|
|
97
|
+
sym = key.to_sym
|
|
98
|
+
@answers[sym] = value unless @answers.key?(sym)
|
|
99
|
+
end
|
|
100
|
+
skip_if_needed unless finished?
|
|
101
|
+
@answers
|
|
102
|
+
end
|
|
103
|
+
|
|
67
104
|
# Serializable state snapshot for persistence or resumption.
|
|
68
105
|
#
|
|
69
106
|
# @return [Hash]
|
|
@@ -71,7 +108,8 @@ module Inquirex
|
|
|
71
108
|
{
|
|
72
109
|
current_step_id: @current_step_id,
|
|
73
110
|
answers: @answers,
|
|
74
|
-
history: @history
|
|
111
|
+
history: @history,
|
|
112
|
+
totals: @totals
|
|
75
113
|
}
|
|
76
114
|
end
|
|
77
115
|
|
|
@@ -96,6 +134,20 @@ module Inquirex
|
|
|
96
134
|
@current_step_id = state[:current_step_id]
|
|
97
135
|
@answers = state[:answers] || {}
|
|
98
136
|
@history = state[:history] || []
|
|
137
|
+
@totals = state[:totals] || init_totals
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def init_totals
|
|
141
|
+
@definition.accumulators.each_with_object({}) do |(name, acc), h|
|
|
142
|
+
h[name] = acc.default
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def apply_accumulations(node, answer)
|
|
147
|
+
node.accumulations.each do |accumulation|
|
|
148
|
+
@totals[accumulation.target] ||= 0
|
|
149
|
+
@totals[accumulation.target] += accumulation.contribution(answer)
|
|
150
|
+
end
|
|
99
151
|
end
|
|
100
152
|
|
|
101
153
|
def advance_step
|
data/lib/inquirex/node.rb
CHANGED
|
@@ -23,6 +23,7 @@ module Inquirex
|
|
|
23
23
|
# @attr_reader transitions [Array<Transition>] ordered conditional next-step edges
|
|
24
24
|
# @attr_reader skip_if [Rules::Base, nil] rule to skip this step entirely
|
|
25
25
|
# @attr_reader default [Object, nil] default value (pre-fill, user can change)
|
|
26
|
+
# @attr_reader widget_hints [Hash{Symbol => WidgetHint}, nil] rendering hints per target
|
|
26
27
|
class Node
|
|
27
28
|
# Valid DSL verbs and which ones collect input from the user.
|
|
28
29
|
VERBS = %i[ask say header btw warning confirm].freeze
|
|
@@ -44,7 +45,9 @@ module Inquirex
|
|
|
44
45
|
:option_labels,
|
|
45
46
|
:transitions,
|
|
46
47
|
:skip_if,
|
|
47
|
-
:default
|
|
48
|
+
:default,
|
|
49
|
+
:widget_hints,
|
|
50
|
+
:accumulations
|
|
48
51
|
|
|
49
52
|
def initialize(id:,
|
|
50
53
|
verb:,
|
|
@@ -54,7 +57,9 @@ module Inquirex
|
|
|
54
57
|
options: nil,
|
|
55
58
|
transitions: [],
|
|
56
59
|
skip_if: nil,
|
|
57
|
-
default: nil
|
|
60
|
+
default: nil,
|
|
61
|
+
widget_hints: nil,
|
|
62
|
+
accumulations: [])
|
|
58
63
|
@id = id.to_sym
|
|
59
64
|
@verb = verb.to_sym
|
|
60
65
|
@type = type&.to_sym
|
|
@@ -63,10 +68,11 @@ module Inquirex
|
|
|
63
68
|
@transitions = transitions.freeze
|
|
64
69
|
@skip_if = skip_if
|
|
65
70
|
@default = default
|
|
71
|
+
@widget_hints = widget_hints&.freeze
|
|
72
|
+
@accumulations = accumulations.freeze
|
|
66
73
|
extract_options(options)
|
|
67
74
|
freeze
|
|
68
75
|
end
|
|
69
|
-
# rubocop:enable Metrics/ParameterLists
|
|
70
76
|
|
|
71
77
|
# @return [Boolean] true if this step collects input from the user
|
|
72
78
|
def collecting?
|
|
@@ -78,6 +84,23 @@ module Inquirex
|
|
|
78
84
|
DISPLAY_VERBS.include?(@verb)
|
|
79
85
|
end
|
|
80
86
|
|
|
87
|
+
# Returns the explicit widget hint for the given target, or nil.
|
|
88
|
+
#
|
|
89
|
+
# @param target [Symbol] e.g. :desktop, :mobile, :tty
|
|
90
|
+
# @return [WidgetHint, nil]
|
|
91
|
+
def widget_hint_for(target: :desktop)
|
|
92
|
+
@widget_hints&.fetch(target.to_sym, nil)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Returns the explicit hint for the target, falling back to the
|
|
96
|
+
# registry default for this node's type when no explicit hint is set.
|
|
97
|
+
#
|
|
98
|
+
# @param target [Symbol] e.g. :desktop, :mobile, :tty
|
|
99
|
+
# @return [WidgetHint, nil]
|
|
100
|
+
def effective_widget_hint_for(target: :desktop)
|
|
101
|
+
widget_hint_for(target:) || WidgetRegistry.default_hint_for(@type, context: target)
|
|
102
|
+
end
|
|
103
|
+
|
|
81
104
|
# Resolves the next step id from current answers by evaluating transitions in order.
|
|
82
105
|
#
|
|
83
106
|
# @param answers [Hash] current answer state
|
|
@@ -115,6 +138,18 @@ module Inquirex
|
|
|
115
138
|
end
|
|
116
139
|
|
|
117
140
|
hash["transitions"] = @transitions.map(&:to_h) unless @transitions.empty?
|
|
141
|
+
|
|
142
|
+
if @widget_hints && !@widget_hints.empty?
|
|
143
|
+
hash["widget"] = @widget_hints.transform_keys(&:to_s)
|
|
144
|
+
.transform_values(&:to_h)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
unless @accumulations.empty?
|
|
148
|
+
hash["accumulate"] = @accumulations.to_h do |acc|
|
|
149
|
+
[acc.target.to_s, acc.to_h]
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
118
153
|
hash
|
|
119
154
|
end
|
|
120
155
|
|
|
@@ -132,10 +167,14 @@ module Inquirex
|
|
|
132
167
|
transitions_data = hash["transitions"] || hash[:transitions] || []
|
|
133
168
|
skip_if_data = hash["skip_if"] || hash[:skip_if]
|
|
134
169
|
default = hash["default"] || hash[:default]
|
|
170
|
+
widget_data = hash["widget"] || hash[:widget]
|
|
171
|
+
accumulate_data = hash["accumulate"] || hash[:accumulate]
|
|
135
172
|
|
|
136
173
|
transitions = transitions_data.map { |t| Transition.from_h(t) }
|
|
137
174
|
skip_if = skip_if_data ? Rules::Base.from_h(skip_if_data) : nil
|
|
138
175
|
options = deserialize_options(raw_options)
|
|
176
|
+
widget_hints = deserialize_widget_hints(widget_data)
|
|
177
|
+
accumulations = deserialize_accumulations(accumulate_data)
|
|
139
178
|
|
|
140
179
|
new(
|
|
141
180
|
id:,
|
|
@@ -146,7 +185,9 @@ module Inquirex
|
|
|
146
185
|
options:,
|
|
147
186
|
transitions:,
|
|
148
187
|
skip_if:,
|
|
149
|
-
default
|
|
188
|
+
default:,
|
|
189
|
+
widget_hints:,
|
|
190
|
+
accumulations:
|
|
150
191
|
)
|
|
151
192
|
end
|
|
152
193
|
|
|
@@ -159,7 +200,6 @@ module Inquirex
|
|
|
159
200
|
@option_labels = raw.transform_keys(&:to_s).transform_values(&:to_s).freeze
|
|
160
201
|
when Array
|
|
161
202
|
if raw.first.is_a?(Hash)
|
|
162
|
-
# Already in [{ "value" => ..., "label" => ... }] format
|
|
163
203
|
@options = raw.map { |o| (o["value"] || o[:value]).to_s }.freeze
|
|
164
204
|
@option_labels = raw.to_h { |o| [(o["value"] || o[:value]).to_s, (o["label"] || o[:label]).to_s] }.freeze
|
|
165
205
|
else
|
|
@@ -188,5 +228,21 @@ module Inquirex
|
|
|
188
228
|
raw.to_h { |o| [(o["value"] || o[:value]).to_s, (o["label"] || o[:label]).to_s] }
|
|
189
229
|
end
|
|
190
230
|
private_class_method :deserialize_options
|
|
231
|
+
|
|
232
|
+
def self.deserialize_accumulations(data)
|
|
233
|
+
return [] unless data.is_a?(Hash)
|
|
234
|
+
|
|
235
|
+
data.map { |target, entry| Accumulation.from_h(target.to_sym, entry) }
|
|
236
|
+
end
|
|
237
|
+
private_class_method :deserialize_accumulations
|
|
238
|
+
|
|
239
|
+
def self.deserialize_widget_hints(widget_data)
|
|
240
|
+
return nil unless widget_data.is_a?(Hash) && widget_data.any? { |_, v| v.is_a?(Hash) }
|
|
241
|
+
|
|
242
|
+
widget_data.each_with_object({}) do |(target, hint_hash), acc|
|
|
243
|
+
acc[target.to_sym] = WidgetHint.from_h(hint_hash)
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
private_class_method :deserialize_widget_hints
|
|
191
247
|
end
|
|
192
248
|
end
|
data/lib/inquirex/version.rb
CHANGED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
# Immutable rendering hint attached to a step node.
|
|
5
|
+
# Carries a widget type (e.g. :radio_group) and an optional options hash
|
|
6
|
+
# (e.g. { columns: 2 }). Framework-agnostic — consumed by the JS widget,
|
|
7
|
+
# TTY adapter, or any other frontend renderer.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# hint = WidgetHint.new(type: :radio_group, options: { columns: 2 })
|
|
11
|
+
# hint.to_h # => { "type" => "radio_group", "columns" => 2 }
|
|
12
|
+
WidgetHint = Data.define(:type, :options) do
|
|
13
|
+
def initialize(type:, options: {})
|
|
14
|
+
super(type: type.to_sym, options: options.transform_keys(&:to_sym).freeze)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Serializes to a flat hash: type key + option keys merged in.
|
|
18
|
+
#
|
|
19
|
+
# @return [Hash<String, Object>]
|
|
20
|
+
def to_h
|
|
21
|
+
{ "type" => type.to_s }.merge(options.transform_keys(&:to_s))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Deserializes from a plain hash (string or symbol keys).
|
|
25
|
+
#
|
|
26
|
+
# @param hash [Hash]
|
|
27
|
+
# @return [WidgetHint]
|
|
28
|
+
def self.from_h(hash)
|
|
29
|
+
type = hash["type"] || hash[:type]
|
|
30
|
+
options = hash.reject { |k, _| k.to_s == "type" }
|
|
31
|
+
.transform_keys(&:to_sym)
|
|
32
|
+
new(type:, options:)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
# Canonical mapping from Inquirex data types to default widget types per rendering context.
|
|
5
|
+
#
|
|
6
|
+
# Desktop defaults lean toward richer controls (radio groups, checkbox groups).
|
|
7
|
+
# Mobile defaults lean toward compact controls (dropdowns, toggles).
|
|
8
|
+
# TTY defaults align with tty-prompt methods.
|
|
9
|
+
#
|
|
10
|
+
# Adapters (TTY, JS, Rails) use this to pick an appropriate renderer when
|
|
11
|
+
# the DSL author has not specified an explicit `widget` hint.
|
|
12
|
+
module WidgetRegistry
|
|
13
|
+
WIDGET_TYPES = %i[
|
|
14
|
+
text_input
|
|
15
|
+
textarea
|
|
16
|
+
number_input
|
|
17
|
+
currency_input
|
|
18
|
+
toggle
|
|
19
|
+
yes_no_buttons
|
|
20
|
+
yes_no
|
|
21
|
+
radio_group
|
|
22
|
+
dropdown
|
|
23
|
+
checkbox_group
|
|
24
|
+
multi_select_dropdown
|
|
25
|
+
select
|
|
26
|
+
multi_select
|
|
27
|
+
enum_select
|
|
28
|
+
multiline
|
|
29
|
+
mask
|
|
30
|
+
slider
|
|
31
|
+
date_picker
|
|
32
|
+
email_input
|
|
33
|
+
phone_input
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
# Default widget per data type and rendering context.
|
|
37
|
+
DEFAULTS = {
|
|
38
|
+
string: { desktop: :text_input, mobile: :text_input, tty: :text_input },
|
|
39
|
+
text: { desktop: :textarea, mobile: :textarea, tty: :multiline },
|
|
40
|
+
integer: { desktop: :number_input, mobile: :number_input, tty: :number_input },
|
|
41
|
+
decimal: { desktop: :number_input, mobile: :number_input, tty: :number_input },
|
|
42
|
+
currency: { desktop: :currency_input, mobile: :currency_input, tty: :number_input },
|
|
43
|
+
boolean: { desktop: :toggle, mobile: :yes_no_buttons, tty: :yes_no },
|
|
44
|
+
enum: { desktop: :radio_group, mobile: :dropdown, tty: :select },
|
|
45
|
+
multi_enum: { desktop: :checkbox_group, mobile: :checkbox_group, tty: :multi_select },
|
|
46
|
+
date: { desktop: :date_picker, mobile: :date_picker, tty: :text_input },
|
|
47
|
+
email: { desktop: :email_input, mobile: :email_input, tty: :text_input },
|
|
48
|
+
phone: { desktop: :phone_input, mobile: :phone_input, tty: :text_input }
|
|
49
|
+
}.freeze
|
|
50
|
+
|
|
51
|
+
# Returns the default widget type for a given data type and context.
|
|
52
|
+
#
|
|
53
|
+
# @param type [Symbol, String, nil] Inquirex data type (e.g. :enum, :integer)
|
|
54
|
+
# @param context [Symbol] :desktop, :mobile, or :tty (default: :desktop)
|
|
55
|
+
# @return [Symbol, nil] widget type symbol, or nil if type is unknown
|
|
56
|
+
def self.default_for(type, context: :desktop)
|
|
57
|
+
DEFAULTS.dig(type&.to_sym, context)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Returns a WidgetHint with the default widget for the given type + context,
|
|
61
|
+
# or nil when no default exists (e.g. for display verbs with no type).
|
|
62
|
+
#
|
|
63
|
+
# @param type [Symbol, String, nil]
|
|
64
|
+
# @param context [Symbol]
|
|
65
|
+
# @return [WidgetHint, nil]
|
|
66
|
+
def self.default_hint_for(type, context: :desktop)
|
|
67
|
+
widget_type = default_for(type, context:)
|
|
68
|
+
widget_type ? WidgetHint.new(type: widget_type) : nil
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
data/lib/inquirex.rb
CHANGED
|
@@ -13,9 +13,14 @@ require_relative "inquirex/rules/not_empty"
|
|
|
13
13
|
require_relative "inquirex/rules/all"
|
|
14
14
|
require_relative "inquirex/rules/any"
|
|
15
15
|
|
|
16
|
+
# Widget hints
|
|
17
|
+
require_relative "inquirex/widget_hint"
|
|
18
|
+
require_relative "inquirex/widget_registry"
|
|
19
|
+
|
|
16
20
|
# Core graph objects
|
|
17
21
|
require_relative "inquirex/transition"
|
|
18
22
|
require_relative "inquirex/evaluator"
|
|
23
|
+
require_relative "inquirex/accumulator"
|
|
19
24
|
require_relative "inquirex/node"
|
|
20
25
|
require_relative "inquirex/definition"
|
|
21
26
|
require_relative "inquirex/answers"
|
metadata
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: inquirex
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Konstantin Gredeskoul
|
|
8
|
-
bindir:
|
|
8
|
+
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies: []
|
|
@@ -18,8 +18,7 @@ description: Inquirex lets you define multi-step questionnaires as directed grap
|
|
|
18
18
|
immutable definitions.
|
|
19
19
|
email:
|
|
20
20
|
- kigster@gmail.com
|
|
21
|
-
executables:
|
|
22
|
-
- inquirex
|
|
21
|
+
executables: []
|
|
23
22
|
extensions: []
|
|
24
23
|
extra_rdoc_files: []
|
|
25
24
|
files:
|
|
@@ -32,10 +31,11 @@ files:
|
|
|
32
31
|
- LICENSE.txt
|
|
33
32
|
- README.md
|
|
34
33
|
- Rakefile
|
|
35
|
-
-
|
|
34
|
+
- docs/badges/coverage_badge.svg
|
|
36
35
|
- justfile
|
|
37
36
|
- lefthook.yml
|
|
38
37
|
- lib/inquirex.rb
|
|
38
|
+
- lib/inquirex/accumulator.rb
|
|
39
39
|
- lib/inquirex/answers.rb
|
|
40
40
|
- lib/inquirex/definition.rb
|
|
41
41
|
- lib/inquirex/dsl.rb
|
|
@@ -60,15 +60,17 @@ files:
|
|
|
60
60
|
- lib/inquirex/validation/adapter.rb
|
|
61
61
|
- lib/inquirex/validation/null_adapter.rb
|
|
62
62
|
- lib/inquirex/version.rb
|
|
63
|
+
- lib/inquirex/widget_hint.rb
|
|
64
|
+
- lib/inquirex/widget_registry.rb
|
|
63
65
|
- sig/inquirex.rbs
|
|
64
|
-
homepage: https://github.com/
|
|
66
|
+
homepage: https://github.com/inquirex/inquirex
|
|
65
67
|
licenses:
|
|
66
68
|
- MIT
|
|
67
69
|
metadata:
|
|
68
70
|
allowed_push_host: https://rubygems.org
|
|
69
|
-
homepage_uri: https://github.com/
|
|
70
|
-
source_code_uri: https://github.com/
|
|
71
|
-
changelog_uri: https://github.com/
|
|
71
|
+
homepage_uri: https://github.com/inquirex/inquirex
|
|
72
|
+
source_code_uri: https://github.com/inquirex/inquirex
|
|
73
|
+
changelog_uri: https://github.com/inquirex/inquirex/blob/main/CHANGELOG.md
|
|
72
74
|
rubygems_mfa_required: 'true'
|
|
73
75
|
rdoc_options: []
|
|
74
76
|
require_paths:
|
data/exe/inquirex
DELETED