alembic 0.1.0

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.
Files changed (168) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +105 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/builds/alembic/canvas.js +1 -0
  6. data/app/assets/builds/alembic/page_builder.css +1 -0
  7. data/app/assets/builds/alembic/page_builder.js +5 -0
  8. data/app/assets/stylesheets/alembic/application.css +15 -0
  9. data/app/controllers/alembic/application_controller.rb +32 -0
  10. data/app/controllers/alembic/flows_controller.rb +104 -0
  11. data/app/controllers/alembic/manage/base_controller.rb +16 -0
  12. data/app/controllers/alembic/manage/canvas_controller.rb +183 -0
  13. data/app/controllers/alembic/manage/definitions_controller.rb +15 -0
  14. data/app/controllers/alembic/manage/flows_controller.rb +55 -0
  15. data/app/controllers/alembic/manage/page_blocks_controller.rb +19 -0
  16. data/app/controllers/alembic/manage/pages_controller.rb +38 -0
  17. data/app/controllers/alembic/manage/previews_controller.rb +38 -0
  18. data/app/controllers/alembic/manage/versions_controller.rb +22 -0
  19. data/app/controllers/concerns/alembic/manage/authenticates_admin.rb +19 -0
  20. data/app/controllers/concerns/alembic/manage/draws_canvas.rb +35 -0
  21. data/app/helpers/alembic/application_helper.rb +11 -0
  22. data/app/javascript/alembic/canvas/Canvas.jsx +197 -0
  23. data/app/javascript/alembic/canvas/Connector.jsx +30 -0
  24. data/app/javascript/alembic/canvas/ConnectorLayer.jsx +31 -0
  25. data/app/javascript/alembic/canvas/Control.jsx +42 -0
  26. data/app/javascript/alembic/canvas/Inspector.jsx +44 -0
  27. data/app/javascript/alembic/canvas/Panel.jsx +67 -0
  28. data/app/javascript/alembic/canvas/Placeholder.jsx +24 -0
  29. data/app/javascript/alembic/canvas/Port.jsx +16 -0
  30. data/app/javascript/alembic/canvas/Records.jsx +34 -0
  31. data/app/javascript/alembic/canvas/StepCard.jsx +57 -0
  32. data/app/javascript/alembic/canvas/Toolbar.jsx +19 -0
  33. data/app/javascript/alembic/canvas/TypePicker.jsx +21 -0
  34. data/app/javascript/alembic/canvas/changes.js +5 -0
  35. data/app/javascript/alembic/canvas/choices.js +4 -0
  36. data/app/javascript/alembic/canvas/flow.js +7 -0
  37. data/app/javascript/alembic/canvas/flowSender.js +22 -0
  38. data/app/javascript/alembic/canvas/ids.js +7 -0
  39. data/app/javascript/alembic/canvas/rows.js +2 -0
  40. data/app/javascript/alembic/canvas/styles.js +3 -0
  41. data/app/javascript/alembic/canvas/useConnectors.js +75 -0
  42. data/app/javascript/alembic/canvas/useFlow.js +17 -0
  43. data/app/javascript/alembic/canvas.jsx +13 -0
  44. data/app/javascript/alembic/page_builder/PageBuilder.jsx +18 -0
  45. data/app/javascript/alembic/page_builder.jsx +10 -0
  46. data/app/jobs/alembic/application_job.rb +4 -0
  47. data/app/mailers/alembic/application_mailer.rb +6 -0
  48. data/app/models/alembic/application_record.rb +5 -0
  49. data/app/models/alembic/asked.rb +3 -0
  50. data/app/models/alembic/choice.rb +7 -0
  51. data/app/models/alembic/flow/admission.rb +19 -0
  52. data/app/models/alembic/flow/canvas.rb +167 -0
  53. data/app/models/alembic/flow/change.rb +18 -0
  54. data/app/models/alembic/flow/condition.rb +24 -0
  55. data/app/models/alembic/flow/definition.rb +132 -0
  56. data/app/models/alembic/flow/digest.rb +116 -0
  57. data/app/models/alembic/flow/document.rb +131 -0
  58. data/app/models/alembic/flow/drawing.rb +15 -0
  59. data/app/models/alembic/flow/edge.rb +9 -0
  60. data/app/models/alembic/flow/edit_history.rb +75 -0
  61. data/app/models/alembic/flow/layout.rb +88 -0
  62. data/app/models/alembic/flow/name.rb +15 -0
  63. data/app/models/alembic/flow/node.rb +5 -0
  64. data/app/models/alembic/flow/output.rb +23 -0
  65. data/app/models/alembic/flow/progress/kept.rb +38 -0
  66. data/app/models/alembic/flow/progress/loose.rb +43 -0
  67. data/app/models/alembic/flow/progress.rb +11 -0
  68. data/app/models/alembic/flow/registry.rb +25 -0
  69. data/app/models/alembic/flow/run.rb +54 -0
  70. data/app/models/alembic/flow/runner.rb +65 -0
  71. data/app/models/alembic/flow/settings.rb +80 -0
  72. data/app/models/alembic/flow/start.rb +13 -0
  73. data/app/models/alembic/flow/step.rb +42 -0
  74. data/app/models/alembic/flow/step_type/declaration.rb +118 -0
  75. data/app/models/alembic/flow/step_type.rb +69 -0
  76. data/app/models/alembic/flow/summaries.rb +44 -0
  77. data/app/models/alembic/flow/summary_version.rb +13 -0
  78. data/app/models/alembic/flow/switch.rb +17 -0
  79. data/app/models/alembic/flow/terminal.rb +13 -0
  80. data/app/models/alembic/flow/validator.rb +171 -0
  81. data/app/models/alembic/flow/version.rb +26 -0
  82. data/app/models/alembic/flow/violation.rb +9 -0
  83. data/app/models/alembic/flow.rb +44 -0
  84. data/app/models/alembic/outputs/band.rb +22 -0
  85. data/app/models/alembic/outputs/grouped.rb +26 -0
  86. data/app/models/alembic/outputs/lowest.rb +20 -0
  87. data/app/models/alembic/outputs/percentage.rb +31 -0
  88. data/app/models/alembic/outputs/tally.rb +23 -0
  89. data/app/models/alembic/outputs/weighted_sum.rb +26 -0
  90. data/app/models/alembic/page.rb +11 -0
  91. data/app/models/alembic/runner.rb +13 -0
  92. data/app/models/alembic/steps/question.rb +52 -0
  93. data/app/models/alembic/summary/output_type.rb +40 -0
  94. data/app/models/alembic/summary/registry.rb +25 -0
  95. data/app/models/alembic/summary/report.rb +31 -0
  96. data/app/models/alembic/summary/result.rb +5 -0
  97. data/app/models/alembic/summary/run.rb +13 -0
  98. data/app/models/alembic/summary.rb +15 -0
  99. data/app/views/alembic/flow/steps/_choosing.html.erb +9 -0
  100. data/app/views/alembic/flows/complete.html.erb +34 -0
  101. data/app/views/alembic/flows/show.html.erb +17 -0
  102. data/app/views/alembic/flows/step.html.erb +27 -0
  103. data/app/views/alembic/manage/definitions/edit.html.erb +13 -0
  104. data/app/views/alembic/manage/flows/edit.html.erb +29 -0
  105. data/app/views/alembic/manage/flows/index.html.erb +32 -0
  106. data/app/views/alembic/manage/flows/show.html.erb +26 -0
  107. data/app/views/alembic/manage/pages/_block_content.html.erb +1 -0
  108. data/app/views/alembic/manage/pages/index.html.erb +20 -0
  109. data/app/views/alembic/manage/pages/show.html.erb +11 -0
  110. data/app/views/alembic/manage/versions/index.html.erb +41 -0
  111. data/app/views/layouts/alembic/application.html.erb +17 -0
  112. data/config/routes.rb +42 -0
  113. data/db/migrate/20260604215031_create_alembic_diagnostics.rb +14 -0
  114. data/db/migrate/20260605015051_create_alembic_questions.rb +12 -0
  115. data/db/migrate/20260605021526_create_alembic_options.rb +14 -0
  116. data/db/migrate/20260605023308_create_alembic_conditions.rb +11 -0
  117. data/db/migrate/20260605025623_create_alembic_bands.rb +12 -0
  118. data/db/migrate/20260605032859_create_alembic_condition_options.rb +10 -0
  119. data/db/migrate/20260605032900_reshape_conditions_for_placement.rb +11 -0
  120. data/db/migrate/20260605125119_create_alembic_results.rb +14 -0
  121. data/db/migrate/20260605130338_create_alembic_rules.rb +10 -0
  122. data/db/migrate/20260605130339_create_alembic_rule_results.rb +10 -0
  123. data/db/migrate/20260605210714_add_definition_to_alembic_diagnostics.rb +5 -0
  124. data/db/migrate/20260606000855_add_guide_copy_to_alembic_diagnostics.rb +9 -0
  125. data/db/migrate/20260606005430_create_alembic_nodes.rb +22 -0
  126. data/db/migrate/20260606010312_create_alembic_build_steps.rb +12 -0
  127. data/db/migrate/20260606011110_create_alembic_warnings.rb +11 -0
  128. data/db/migrate/20260804180000_create_alembic_domains.rb +14 -0
  129. data/db/migrate/20260804180100_add_domain_to_alembic_questions.rb +5 -0
  130. data/db/migrate/20260822120000_create_alembic_definition_versions.rb +14 -0
  131. data/db/migrate/20260822120100_backfill_alembic_definition_versions.rb +17 -0
  132. data/db/migrate/20260822130000_remove_definition_from_alembic_diagnostics.rb +28 -0
  133. data/db/migrate/20260822140000_create_alembic_responses.rb +14 -0
  134. data/db/migrate/20260824120000_create_alembic_transitions.rb +10 -0
  135. data/db/migrate/20260824130000_drop_alembic_builder_tables.rb +17 -0
  136. data/db/migrate/20260824140000_add_definition_cursor_to_alembic_diagnostics.rb +5 -0
  137. data/db/migrate/20260824160000_add_summary_definition_to_alembic_diagnostics.rb +5 -0
  138. data/db/migrate/20260824170000_drop_alembic_guide_copy_columns.rb +8 -0
  139. data/db/migrate/20260825120000_create_alembic_summary_versions.rb +14 -0
  140. data/db/migrate/20260825130000_add_summary_cursor_to_alembic_diagnostics.rb +5 -0
  141. data/db/migrate/20260825140000_move_summary_definitions_into_versions.rb +26 -0
  142. data/db/migrate/20260825150000_drop_summary_definition_from_alembic_diagnostics.rb +5 -0
  143. data/db/migrate/20260825160000_add_summary_version_to_alembic_responses.rb +6 -0
  144. data/db/migrate/20260825170000_rename_question_settings_in_current_definitions.rb +51 -0
  145. data/db/migrate/20260825180000_add_document_to_alembic_diagnostics.rb +6 -0
  146. data/db/migrate/20260825190000_seed_documents_from_current_definitions.rb +29 -0
  147. data/db/migrate/20260825200000_add_published_version_to_alembic_diagnostics.rb +6 -0
  148. data/db/migrate/20260825210000_publish_current_definitions.rb +29 -0
  149. data/db/migrate/20260825220000_add_undone_changes_to_alembic_diagnostics.rb +5 -0
  150. data/db/migrate/20260825223322_add_status_to_alembic_definition_versions.rb +8 -0
  151. data/db/migrate/20260825224538_remove_published_version_from_alembic_diagnostics.rb +5 -0
  152. data/db/migrate/20260825225015_change_alembic_diagnostic_status_to_availability.rb +16 -0
  153. data/db/migrate/20260825230000_add_undo_history_to_alembic_diagnostics.rb +5 -0
  154. data/db/migrate/20260825230500_add_changes_to_alembic_definition_versions.rb +5 -0
  155. data/db/migrate/20260826230000_rename_alembic_responses_to_flow_runs.rb +5 -0
  156. data/db/migrate/20260826234500_rename_answers_to_recorded_on_flow_runs.rb +5 -0
  157. data/db/migrate/20260826235000_rename_definition_versions_to_flow_versions.rb +5 -0
  158. data/db/migrate/20260826235500_rename_summary_versions_to_flow_summaries.rb +5 -0
  159. data/db/migrate/20260827000000_rename_diagnostics_to_flows.rb +8 -0
  160. data/db/migrate/20260831000000_add_persists_to_alembic_flows.rb +5 -0
  161. data/db/migrate/20260914120000_create_alembic_pages.rb +9 -0
  162. data/db/migrate/20260915120000_add_blocks_to_alembic_pages.rb +5 -0
  163. data/lib/alembic/engine.rb +43 -0
  164. data/lib/alembic/leftover_stylesheet.rb +14 -0
  165. data/lib/alembic/version.rb +3 -0
  166. data/lib/alembic.rb +34 -0
  167. data/lib/tasks/alembic_tasks.rake +4 -0
  168. metadata +252 -0
@@ -0,0 +1,44 @@
1
+ module Alembic
2
+ module Flow
3
+ class Summaries
4
+ def initialize(flow)
5
+ @flow = flow
6
+ end
7
+
8
+ def document
9
+ current_version&.summary
10
+ end
11
+
12
+ def current_version
13
+ @flow.summary_versions.find_by(number: cursor)
14
+ end
15
+
16
+ def record(payload)
17
+ @flow.summary_versions.create!(number: next_number, summary: payload)
18
+ .tap { |version| @flow.update!(summary_cursor: version.number) }
19
+ end
20
+
21
+ def any?
22
+ document.present?
23
+ end
24
+
25
+ def of(state)
26
+ Summary::Report.new(document).results(Summary::Run.new(state: state, steps: steps_by_id))
27
+ end
28
+
29
+ private
30
+
31
+ def steps_by_id
32
+ Array(@flow.definition.to_h["nodes"]).index_by { |node| node["id"] }
33
+ end
34
+
35
+ def cursor
36
+ @flow.summary_cursor || @flow.summary_versions.pluck(:number).max
37
+ end
38
+
39
+ def next_number
40
+ (@flow.summary_versions.maximum(:number) || 0) + 1
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,13 @@
1
+ module Alembic
2
+ module Flow
3
+ class SummaryVersion < ApplicationRecord
4
+ self.table_name = "alembic_flow_summaries"
5
+
6
+ belongs_to :flow, class_name: "Alembic::Flow::Definition"
7
+
8
+ validates :number, uniqueness: { scope: :flow_id }
9
+
10
+ before_update { raise ActiveRecord::ReadOnlyRecord }
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,17 @@
1
+ module Alembic
2
+ module Flow
3
+ class Switch
4
+ include Step
5
+
6
+ step_name "Switch"
7
+
8
+ setting :step, type: :previous_step
9
+
10
+ output :choice, type: :string, label: "Choice", from: :step
11
+
12
+ def route(node, state)
13
+ state[node.config["step"]]
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,13 @@
1
+ module Alembic
2
+ module Flow
3
+ class Terminal
4
+ include Step
5
+
6
+ step_name "End"
7
+
8
+ ends_here
9
+
10
+ names_by { |_node| "End" }
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,171 @@
1
+ module Alembic
2
+ module Flow
3
+ class Validator
4
+ OPTIONAL = { unrouted_value: :unrouted_values, unfollowed_path: :unfollowed_paths, dead_end: :dead_ends }.freeze
5
+
6
+ def initialize(document, registry: Flow.registry, checks: Flow.checks)
7
+ @document = document
8
+ @registry = registry
9
+ @checks = checks
10
+ end
11
+
12
+ def violations
13
+ structural_violations + unmet_requirements + missing_settings + missing_values + beginnings + asked_for
14
+ end
15
+
16
+ def structural_violations
17
+ malformations + unreachable
18
+ end
19
+
20
+ def malformations
21
+ missing_edge_targets + missing_edge_sources + duplicate_ids + no_beginning
22
+ end
23
+
24
+ private
25
+
26
+ def asked_for
27
+ @checks.flat_map { |name| send(OPTIONAL.fetch(name)) }
28
+ end
29
+
30
+ def missing_edge_targets
31
+ @document.edges.reject { |edge| known?(edge.to) }
32
+ .map { |edge| Violation.new(node: edge.from, problem: :missing_edge_target, detail: edge.to) }
33
+ end
34
+
35
+ def missing_edge_sources
36
+ @document.edges.reject { |edge| known?(edge.from) }
37
+ .map { |edge| Violation.new(node: edge.to, problem: :missing_edge_source, detail: edge.from) }
38
+ end
39
+
40
+ def duplicate_ids
41
+ @document.nodes.map(&:id).tally.select { |_id, count| count > 1 }
42
+ .map { |id, _count| Violation.new(node: id, problem: :duplicate_id) }
43
+ end
44
+
45
+ def no_beginning
46
+ return [] if known?(@document.entry)
47
+
48
+ [ Violation.new(node: nil, problem: :no_beginning) ]
49
+ end
50
+
51
+ def unmet_requirements
52
+ @document.nodes.flat_map do |node|
53
+ requirements_for(node).reject { |required| precedes_every_path?(required, node.id) }
54
+ .map { |required| Violation.new(node: node.id, problem: :unmet_requirement, detail: required) }
55
+ end
56
+ end
57
+
58
+ def dead_ends
59
+ @document.nodes.filter_map { |node| ending_problem(node) }
60
+ end
61
+
62
+ def ending_problem(node)
63
+ leaving = @document.edges_from(node.id).any?
64
+ return Violation.new(node: node.id, problem: :past_the_end) if ends_here?(node) && leaving
65
+ return if ends_here?(node) || leaving
66
+
67
+ Violation.new(node: node.id, problem: :dead_end)
68
+ end
69
+
70
+ def ends_here?(node)
71
+ @registry.registered?(node.type) && @registry.fetch(node.type).ends_here?
72
+ end
73
+
74
+ def unfollowed_paths
75
+ @document.nodes.select { |node| leads_on?(node) }.flat_map { |node| unfollowed_paths_from(node) }
76
+ end
77
+
78
+ def leads_on?(node)
79
+ @registry.registered?(node.type) && !@registry.fetch(node.type).routes?
80
+ end
81
+
82
+ def unfollowed_paths_from(node)
83
+ @document.edges_from(node.id).drop(1)
84
+ .map { |edge| Violation.new(node: node.id, problem: :unfollowed_path, detail: edge.to) }
85
+ end
86
+
87
+ def unrouted_values
88
+ @document.nodes.flat_map { |node| unrouted_values_in(node) }
89
+ end
90
+
91
+ def unrouted_values_in(node)
92
+ wired = @document.edges_from(node.id).map { |edge| edge.on.to_s }
93
+
94
+ digest.routing_values(node.id).reject { |value| wired.include?(value) }
95
+ .map { |value| Violation.new(node: node.id, problem: :unrouted_value, detail: value) }
96
+ end
97
+
98
+ def digest
99
+ Digest.new(@document, registry: @registry)
100
+ end
101
+
102
+ def beginnings
103
+ @document.beginnings.drop(1).map { |id| Violation.new(node: id, problem: :many_beginnings) } + led_into
104
+ end
105
+
106
+ def led_into
107
+ @document.beginnings.select { |id| @document.edges.any? { |edge| edge.to == id } }
108
+ .map { |id| Violation.new(node: id, problem: :before_the_beginning) }
109
+ end
110
+
111
+ def missing_values
112
+ @document.nodes.flat_map { |node| missing_values_in(node) }
113
+ end
114
+
115
+ def missing_values_in(node)
116
+ drawn_of(node).filter_map do |name, source|
117
+ chosen = node.config[name.to_s]
118
+ next if chosen.blank? || node.config[source.to_s].blank?
119
+ next if offered_to(node, source).include?(chosen.to_s)
120
+
121
+ Violation.new(node: node.id, problem: :missing_value, detail: chosen.to_s)
122
+ end
123
+ end
124
+
125
+ def offered_to(node, source)
126
+ naming = @registry.fetch(node.type).settings.outputs_of[source]
127
+
128
+ digest.values_of(node.config[naming.to_s], node.config[source.to_s]).map { |value| value["value"].to_s }
129
+ end
130
+
131
+ def drawn_of(node)
132
+ return {} unless @registry.registered?(node.type)
133
+
134
+ @registry.fetch(node.type).settings.drawn_from
135
+ end
136
+
137
+ def missing_settings
138
+ @document.nodes.flat_map do |node|
139
+ required_of(node).reject { |name| node.config[name.to_s].present? }
140
+ .map { |name| Violation.new(node: node.id, problem: :missing_setting, detail: name.to_s) }
141
+ end
142
+ end
143
+
144
+ def required_of(node)
145
+ return [] unless @registry.registered?(node.type)
146
+
147
+ @registry.fetch(node.type).settings.required
148
+ end
149
+
150
+ def requirements_for(node)
151
+ return [] unless @registry.registered?(node.type)
152
+
153
+ @registry.fetch(node.type).settings.requirements_for(node.config)
154
+ end
155
+
156
+ def precedes_every_path?(required, id)
157
+ known?(required) && !@document.reachable(without: required).include?(id)
158
+ end
159
+
160
+ def unreachable
161
+ return [] unless known?(@document.entry)
162
+
163
+ (@document.nodes.map(&:id).uniq - @document.reachable).map { |id| Violation.new(node: id, problem: :unreachable) }
164
+ end
165
+
166
+ def known?(id)
167
+ @document.node(id).present?
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,26 @@
1
+ module Alembic
2
+ module Flow
3
+ class Version < ApplicationRecord
4
+ self.table_name = "alembic_flow_versions"
5
+
6
+ enum :status, { draft: "draft", live: "live", superseded: "superseded",
7
+ retired: "retired", withdrawn: "withdrawn" }
8
+
9
+ belongs_to :flow, class_name: "Alembic::Flow::Definition"
10
+
11
+ validates :number, uniqueness: { scope: :flow_id }
12
+
13
+ def out_of_service?
14
+ retired? || withdrawn?
15
+ end
16
+
17
+ def changes
18
+ changes_captured.to_a
19
+ end
20
+
21
+ FROZEN = %w[definition number flow_id changes_captured].freeze
22
+
23
+ before_update { raise ActiveRecord::ReadOnlyRecord if changed.intersect?(FROZEN) }
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,9 @@
1
+ module Alembic
2
+ module Flow
3
+ Violation = Data.define(:node, :problem, :detail) do
4
+ def initialize(node:, problem:, detail: nil)
5
+ super
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,44 @@
1
+ module Alembic
2
+ module Flow
3
+ FIELD_TYPES = %i[string integer float boolean select multi_select previous_step from_step list].freeze
4
+
5
+ OUTPUT_TYPES = %i[string integer float boolean].freeze
6
+ OPTIONAL_CHECKS = %i[unrouted_value unfollowed_path dead_end].freeze
7
+
8
+ class UnknownFieldType < ArgumentError; end
9
+ class UnknownOutputType < ArgumentError; end
10
+ class UnknownStepType < KeyError; end
11
+ class UnknownCheck < ArgumentError; end
12
+ class InvalidEdit < StandardError; end
13
+
14
+ DRAWING = "alembic/flow/steps/choosing".freeze
15
+
16
+ class << self
17
+ def draws_with(template)
18
+ @drawing = template
19
+ end
20
+
21
+ def drawing
22
+ @drawing.presence || DRAWING
23
+ end
24
+
25
+ def step(id, &declaration)
26
+ registry.register(StepType.define(id, &declaration))
27
+ end
28
+
29
+ def registry
30
+ @registry ||= Registry.new
31
+ end
32
+
33
+ def check(name)
34
+ raise UnknownCheck, "#{name} is not one of #{OPTIONAL_CHECKS.join(', ')}" unless OPTIONAL_CHECKS.include?(name)
35
+
36
+ checks << name unless checks.include?(name)
37
+ end
38
+
39
+ def checks
40
+ @checks ||= []
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,22 @@
1
+ module Alembic
2
+ module Outputs
3
+ module Band
4
+ def self.output_type
5
+ Summary::OutputType.define(:band) do
6
+ label "Band"
7
+ compute { |config, _run, so_far| Band.covering(config, so_far[config["of"]]) }
8
+ end
9
+ end
10
+
11
+ def self.register(registry = Summary.registry)
12
+ registry.register(output_type)
13
+ end
14
+
15
+ def self.covering(config, number)
16
+ ordered = Array(config["bands"]).sort_by { |band| band["ceiling"] || Float::INFINITY }
17
+
18
+ ordered.find { |band| band["ceiling"].nil? || number < band["ceiling"] }&.fetch("name", nil)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,26 @@
1
+ module Alembic
2
+ module Outputs
3
+ module Grouped
4
+ def self.output_type
5
+ Summary::OutputType.define(:grouped) do
6
+ label "By area"
7
+ compute { |config, run, _so_far| Grouped.shares(run, config["by"].presence) }
8
+ end
9
+ end
10
+
11
+ def self.register(registry = Summary.registry)
12
+ registry.register(output_type)
13
+ end
14
+
15
+ def self.bucket(step, marker)
16
+ marker ? step[marker] : Steps::Question.category_of(step)
17
+ end
18
+
19
+ def self.shares(run, marker)
20
+ run.state.keys.group_by { |id| Grouped.bucket(run.step(id), marker) }
21
+ .except(nil)
22
+ .transform_values { |ids| Percentage.of(run, ids) }
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,20 @@
1
+ module Alembic
2
+ module Outputs
3
+ module Lowest
4
+ def self.output_type
5
+ Summary::OutputType.define(:lowest) do
6
+ label "Weakest"
7
+ compute { |config, _run, so_far| Lowest.of(so_far[config["of"]], config["count"]) }
8
+ end
9
+ end
10
+
11
+ def self.register(registry = Summary.registry)
12
+ registry.register(output_type)
13
+ end
14
+
15
+ def self.of(shares, count)
16
+ shares.to_h.sort_by { |_name, share| share }.first(count.presence&.to_i || 1).map(&:first)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,31 @@
1
+ module Alembic
2
+ module Outputs
3
+ module Percentage
4
+ def self.output_type
5
+ Summary::OutputType.define(:percentage) do
6
+ label "Share"
7
+ compute { |_config, run, _so_far| Percentage.of(run, run.state.keys) }
8
+ end
9
+ end
10
+
11
+ def self.register(registry = Summary.registry)
12
+ registry.register(output_type)
13
+ end
14
+
15
+ def self.of(run, ids)
16
+ offered = ids.sum { |id| most(run.step(id)) }
17
+ return 0 if offered.zero?
18
+
19
+ (captured(run, ids).to_f / offered * 100).round
20
+ end
21
+
22
+ def self.captured(run, ids)
23
+ ids.sum { |id| WeightedSum.weight_of(run.step(id), run.state[id]) }
24
+ end
25
+
26
+ def self.most(step)
27
+ Steps::Question.answers_of(step).filter_map { |option| option["weight"].to_i if option.is_a?(Hash) }.max.to_i
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,23 @@
1
+ module Alembic
2
+ module Outputs
3
+ module Tally
4
+ def self.output_type
5
+ Summary::OutputType.define(:tally) do
6
+ label "How many"
7
+ compute { |config, run, _so_far| Tally.counted(run, config["tag"], config["by"].presence) }
8
+ end
9
+ end
10
+
11
+ def self.register(registry = Summary.registry)
12
+ registry.register(output_type)
13
+ end
14
+
15
+ def self.counted(run, tag, marker)
16
+ answered = run.state.keys
17
+ return answered.size if tag.blank?
18
+
19
+ answered.count { |id| Grouped.bucket(run.step(id), marker) == tag }
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,26 @@
1
+ module Alembic
2
+ module Outputs
3
+ module WeightedSum
4
+ def self.output_type
5
+ Summary::OutputType.define(:weighted_sum) do
6
+ label "Score"
7
+ compute { |_config, run, _so_far| WeightedSum.total(run) }
8
+ end
9
+ end
10
+
11
+ def self.register(registry = Summary.registry)
12
+ registry.register(output_type)
13
+ end
14
+
15
+ def self.total(run)
16
+ run.state.sum { |id, value| weight_of(run.step(id), value) }
17
+ end
18
+
19
+ def self.weight_of(step, value)
20
+ chosen = Steps::Question.answers_of(step).find { |option| option.is_a?(Hash) && option["value"] == value }
21
+
22
+ chosen&.fetch("weight", nil).to_i
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,11 @@
1
+ require "ks_blocks/layout"
2
+
3
+ module Alembic
4
+ class Page < ApplicationRecord
5
+ include KsBlocks::Layout
6
+
7
+ block_layout :blocks, kind: :pages
8
+
9
+ validates :name, presence: true
10
+ end
11
+ end
@@ -0,0 +1,13 @@
1
+ module Alembic
2
+ class Runner < Flow::Runner
3
+ def question_text(id)
4
+ Steps::Question.asked(step(id)&.config)
5
+ end
6
+
7
+ def choice_label(id, value)
8
+ chosen = Steps::Question.choices_in(step(id)).find { |choice| choice.value == value }
9
+
10
+ chosen&.label.presence || value
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,52 @@
1
+ module Alembic
2
+ module Steps
3
+ class Question
4
+ include Flow::Step
5
+
6
+ step_name "Question"
7
+
8
+ setting :question, type: :string
9
+ setting :category, type: :string
10
+ setting :answers, type: :list, required: true do
11
+ setting :value, type: :string
12
+ setting :label, type: :string
13
+ setting :weight, type: :integer
14
+ end
15
+
16
+ output :answer, type: :string, label: "Answer", values: ->(node) { Question.offered(node.config) }
17
+
18
+ names_by :question
19
+ awaits_input
20
+
21
+ displays_by { |node| Asked.new(id: node.id.to_sym, text: asked(node.config), choices: choices_in(node)) }
22
+
23
+ def self.asked(step)
24
+ step.to_h["question"] || step.to_h["text"]
25
+ end
26
+
27
+ def self.answers_of(step)
28
+ Array(step.to_h["answers"] || step.to_h["options"])
29
+ end
30
+
31
+ def self.offered(step)
32
+ answers_of(step).map do |answer|
33
+ { "value" => answer["value"], "label" => answer["label"].presence || answer["value"] }
34
+ end
35
+ end
36
+
37
+ def self.choices_in(node)
38
+ answers_of(node.config).map { |answer| choice_from(answer) }
39
+ end
40
+
41
+ def self.choice_from(answer)
42
+ return Choice.new(value: answer) unless answer.is_a?(Hash)
43
+
44
+ Choice.new(value: answer["value"], label: answer["label"], hint: answer["hint"])
45
+ end
46
+
47
+ def self.category_of(step)
48
+ step.to_h["category"] || step.to_h["tag"]
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,40 @@
1
+ module Alembic
2
+ module Summary
3
+ class OutputType
4
+ def self.define(id, &declaration)
5
+ Declaration.new(id).tap { |decl| decl.instance_eval(&declaration) }.to_output_type
6
+ end
7
+
8
+ attr_reader :id, :label
9
+
10
+ def initialize(id:, label:, computation:)
11
+ @id = id
12
+ @label = label
13
+ @computation = computation
14
+ end
15
+
16
+ def compute(config, state, so_far)
17
+ @computation&.call(config, state, so_far)
18
+ end
19
+
20
+ class Declaration
21
+ def initialize(id)
22
+ @id = id
23
+ @label = id.to_s
24
+ end
25
+
26
+ def label(value)
27
+ @label = value
28
+ end
29
+
30
+ def compute(&computation)
31
+ @computation = computation
32
+ end
33
+
34
+ def to_output_type
35
+ OutputType.new(id: @id, label: @label, computation: @computation)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,25 @@
1
+ module Alembic
2
+ module Summary
3
+ class Registry
4
+ def initialize
5
+ @output_types = {}
6
+ end
7
+
8
+ def register(output_type)
9
+ @output_types[output_type.id.to_sym] = output_type
10
+ end
11
+
12
+ def fetch(id)
13
+ @output_types.fetch(id.to_sym) { raise UnknownOutputType, "no output type registered as #{id}" }
14
+ end
15
+
16
+ def registered?(id)
17
+ id.present? && @output_types.key?(id.to_sym)
18
+ end
19
+
20
+ def output_types
21
+ @output_types.values
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,31 @@
1
+ module Alembic
2
+ module Summary
3
+ class Report
4
+ def initialize(configuration, registry: Summary.registry)
5
+ @configuration = configuration.to_h
6
+ @registry = registry
7
+ end
8
+
9
+ def results(run)
10
+ produced = {}
11
+
12
+ outputs.map do |output|
13
+ output_type = @registry.fetch(output["type"])
14
+ produced[output["id"]] = output_type.compute(output, run, produced)
15
+
16
+ Result.new(id: output["id"], label: label_for(output, output_type), value: produced[output["id"]])
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def outputs
23
+ Array(@configuration["outputs"])
24
+ end
25
+
26
+ def label_for(output, output_type)
27
+ output["label"].presence || output_type.label
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,5 @@
1
+ module Alembic
2
+ module Summary
3
+ Result = Data.define(:id, :label, :value)
4
+ end
5
+ end