wrangle 0.1.0 → 0.2.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.
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wrangle
4
+ # Verifies the intended target-specific postcondition after a delivered native action. A changed
5
+ # desktop revision is never proof by itself: unrelated animation, status text, or another control
6
+ # may have changed at the same time. When the intended target cannot be identified or its required
7
+ # state is outside partial coverage, the effect remains unverified.
8
+ class DesktopEffect
9
+ OPERATIONS = %w[PRESS SET_TEXT CLEAR TOGGLE EXPAND COLLAPSE].freeze
10
+ TOGGLE_ROLES = %w[checkbox switch radiobutton].freeze
11
+
12
+ class << self
13
+ def verify(proposal, before, after)
14
+ return "unverified" unless before.is_a?(Hash) && after.is_a?(Hash)
15
+
16
+ operation = proposal["operation"].to_s
17
+ candidate = proposal["candidate"]
18
+ return "unverified" unless OPERATIONS.include?(operation) && candidate.is_a?(Hash)
19
+
20
+ match, target = find_target(candidate, after)
21
+ return missing_target_effect(operation, before, after) if match == :missing
22
+ return "unverified" unless match == :found
23
+
24
+ case operation
25
+ when "PRESS" then verify_press(candidate, target, before, after)
26
+ when "SET_TEXT" then verify_set_text(candidate, target, proposal["text"], before, after)
27
+ when "CLEAR" then verify_clear(candidate, target, before, after)
28
+ when "TOGGLE" then verify_toggle(candidate, target, before, after)
29
+ when "EXPAND", "COLLAPSE" then verify_expansion(operation, candidate, target, before, after)
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def find_target(candidate, observation)
36
+ elements = observation_elements(observation)
37
+ target_key = candidate["target_key"].to_s
38
+ unless target_key.empty?
39
+ keyed = elements.select do |element|
40
+ element["target_key"] == target_key && element["role"].to_s == candidate["role"].to_s
41
+ end
42
+ return [:found, keyed.first] if keyed.one?
43
+ return [:ambiguous, nil] if keyed.length > 1
44
+ end
45
+
46
+ visible = elements.select { |element| same_visible_identity?(candidate, element) }
47
+ return [:found, visible.first] if visible.one?
48
+ return [:missing, nil] if visible.empty?
49
+
50
+ [:ambiguous, nil]
51
+ end
52
+
53
+ def observation_elements(observation)
54
+ tree = observation["tree"]
55
+ return Array(observation["candidates"]) unless tree.is_a?(Hash)
56
+
57
+ elements = []
58
+ stack = [tree]
59
+ until stack.empty?
60
+ node = stack.pop
61
+ elements << normalize_tree_node(node)
62
+ stack.concat(Array(node["children"]).grep(Hash))
63
+ end
64
+ elements
65
+ end
66
+
67
+ def normalize_tree_node(node)
68
+ {
69
+ "role" => node["role"], "subrole" => node["subrole"], "label" => node["name"],
70
+ "value" => node["value"], "states" => node["states"], "operations" => node["operations"],
71
+ "children_count" => node["children_count"], "target_key" => node["target_key"]
72
+ }
73
+ end
74
+
75
+ def same_visible_identity?(before, after)
76
+ before["role"].to_s == after["role"].to_s &&
77
+ before["subrole"].to_s == after["subrole"].to_s &&
78
+ before["label"].to_s == after["label"].to_s
79
+ end
80
+
81
+ def missing_target_effect(operation, before, after)
82
+ return "unverified" unless operation == "PRESS" && complete?(after)
83
+
84
+ changed_revision?(before, after) ? "verified" : "unverified"
85
+ end
86
+
87
+ def verify_press(candidate, target, before, after)
88
+ if calculator_digit_press_effect?(candidate, before, after)
89
+ return changed_revision?(before, after) ? "verified" : "unverified"
90
+ end
91
+ return "unchanged" if press_signature(candidate) == press_signature(target)
92
+
93
+ changed_revision?(before, after) ? "verified" : "unverified"
94
+ end
95
+
96
+ def calculator_digit_press_effect?(candidate, before, after)
97
+ digit = candidate["label"].to_s
98
+ return false unless candidate["role"] == "button" && digit.match?(/\A\d\z/)
99
+ return false unless same_calculator_scope?(before, after) && complete?(before) && complete?(after)
100
+
101
+ previous = calculator_display(before)
102
+ current = calculator_display(after)
103
+ return false unless previous && current
104
+
105
+ expected = previous == "0" ? digit : "#{previous}#{digit}"
106
+ current == expected
107
+ end
108
+
109
+ def same_calculator_scope?(before, after)
110
+ before_scope = before["scope"]
111
+ after_scope = after["scope"]
112
+ before_scope.is_a?(Hash) && after_scope.is_a?(Hash) &&
113
+ before_scope["app"] == "Calculator" && after_scope["app"] == "Calculator" &&
114
+ !before_scope["id"].to_s.empty? && before_scope["id"] == after_scope["id"]
115
+ end
116
+
117
+ def calculator_display(observation)
118
+ values = observation_elements(observation).filter_map do |element|
119
+ next unless element["role"] == "statictext" && element["value"].is_a?(String)
120
+
121
+ normalized = element["value"].gsub(/[^0-9.-]/, "")
122
+ normalized unless normalized.empty?
123
+ end.uniq
124
+ values.one? ? values.first : nil
125
+ end
126
+
127
+ def press_signature(candidate)
128
+ {
129
+ "label" => candidate["label"].to_s, "value" => candidate["value"],
130
+ "states" => Array(candidate["states"]).map(&:to_s).reject { |state| state == "focused" }.uniq.sort,
131
+ "operations" => Array(candidate["operations"]).map(&:to_s).reject { |op| op == "DRILL" }.uniq.sort,
132
+ "children_count" => candidate["children_count"]
133
+ }
134
+ end
135
+
136
+ def verify_set_text(candidate, target, text, before, after)
137
+ return "unverified" unless text.is_a?(String) && same_visible_identity?(candidate, target)
138
+ return "unchanged" unless target["value"] == text
139
+ return "unchanged" if candidate["value"] == text
140
+
141
+ changed_revision?(before, after) ? "verified" : "unverified"
142
+ end
143
+
144
+ def verify_clear(candidate, target, before, after)
145
+ return "unverified" unless same_visible_identity?(candidate, target) && target["value"].is_a?(String)
146
+ return "unchanged" unless target["value"].strip.empty?
147
+ return "unchanged" if candidate["value"].is_a?(String) && candidate["value"].strip.empty?
148
+
149
+ changed_revision?(before, after) ? "verified" : "unverified"
150
+ end
151
+
152
+ def verify_toggle(candidate, target, before, after)
153
+ return "unverified" unless same_visible_identity?(candidate, target)
154
+
155
+ previous = toggle_value(candidate)
156
+ current = toggle_value(target)
157
+ return "unverified" if previous.nil? || current.nil?
158
+ return "unchanged" if previous == current
159
+
160
+ changed_revision?(before, after) ? "verified" : "unverified"
161
+ end
162
+
163
+ def toggle_value(candidate)
164
+ return unless TOGGLE_ROLES.include?(candidate["role"].to_s)
165
+
166
+ scalar = candidate["value"]
167
+ return scalar if [true, false].include?(scalar)
168
+ return scalar == 1 if [0, 1].include?(scalar)
169
+
170
+ states = Array(candidate["states"]).map(&:to_s)
171
+ states.include?("checked") || states.include?("selected")
172
+ end
173
+
174
+ def verify_expansion(operation, candidate, target, before, after)
175
+ return "unverified" unless same_visible_identity?(candidate, target) &&
176
+ candidate.key?("states") && target.key?("states")
177
+
178
+ previous = Array(candidate["states"]).map(&:to_s).include?("expanded")
179
+ current = Array(target["states"]).map(&:to_s).include?("expanded")
180
+ expected_previous = operation == "COLLAPSE"
181
+ return "unverified" unless previous == expected_previous
182
+ return "unchanged" if previous == current
183
+
184
+ changed_revision?(before, after) ? "verified" : "unverified"
185
+ end
186
+
187
+ def complete?(observation)
188
+ observation["complete"] == true && observation.dig("coverage", "truncated") != true
189
+ end
190
+
191
+ def changed_revision?(before, after)
192
+ previous = before["revision"]
193
+ current = after["revision"]
194
+ previous.is_a?(String) && current.is_a?(String) && !previous.empty? && previous != current
195
+ end
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ require_relative "errors"
7
+ require_relative "observation"
8
+
9
+ module Wrangle
10
+ # Normalizes a driver's hierarchical AX snapshot without throwing away its progressive structure.
11
+ class DesktopObservation
12
+ TEXT_ROLES = %w[textfield textarea searchfield combobox].freeze
13
+ TOGGLE_ROLES = %w[checkbox switch radiobutton].freeze
14
+ WINDOW_CONTROL = /\A(?:close|close window|close all|quit(?: .*)?|minimize|zoom|full ?screen)\z/i
15
+ VISIBLE_TEXT_CHARS = 500
16
+
17
+ attr_reader :state
18
+
19
+ class << self
20
+ # Provider and result evidence includes non-actionable text as well as controls. Returning only
21
+ # candidates made read-only tasks impossible to prove and allowed DONE without seeing the fact.
22
+ def evidence_items(observation)
23
+ items = []
24
+ seen = {}
25
+ collect_evidence(observation["tree"], items, seen) if observation["tree"].is_a?(Hash)
26
+ Array(observation["candidates"]).each do |candidate|
27
+ add_evidence(items, seen, candidate.slice("role", "label", "value", "states"))
28
+ end
29
+ items
30
+ end
31
+
32
+ # An action is addressable only by the semantic fields exposed to the provider. Opaque AX refs,
33
+ # paths, and target keys must never break a tie the provider cannot see.
34
+ def action_descriptor(candidate, operation)
35
+ {
36
+ "operation" => operation.to_s, "role" => candidate["role"].to_s,
37
+ "label" => bounded_visible(candidate["label"].to_s),
38
+ "value" => bounded_visible(scalar_value(candidate["value"])),
39
+ "states" => Array(candidate["states"]).map(&:to_s).uniq.sort
40
+ }.compact
41
+ end
42
+
43
+ def action_ambiguities(candidates)
44
+ entries = action_entries(candidates)
45
+ counts = entries.tally
46
+ seen = {}
47
+ entries.filter_map do |descriptor|
48
+ next unless counts[descriptor] > 1
49
+ next if seen[descriptor]
50
+
51
+ seen[descriptor] = true
52
+ descriptor.merge("matches" => counts[descriptor])
53
+ end
54
+ end
55
+
56
+ def action_unambiguous?(candidates, candidate, operation)
57
+ wanted = action_descriptor(candidate, operation)
58
+ Array(candidates).one? do |possible|
59
+ Array(possible["operations"]).map(&:to_s).uniq.include?(operation.to_s) &&
60
+ action_descriptor(possible, operation) == wanted
61
+ end
62
+ end
63
+
64
+ def reject_ambiguous_actions(candidates)
65
+ ambiguities = action_ambiguities(candidates)
66
+ ambiguous = ambiguities.to_h { |descriptor| [descriptor.except("matches"), true] }
67
+ filtered = Array(candidates).filter_map do |candidate|
68
+ operations = Array(candidate["operations"]).map(&:to_s).uniq.reject do |operation|
69
+ ambiguous[action_descriptor(candidate, operation)]
70
+ end
71
+ candidate.merge("operations" => operations) unless operations.empty?
72
+ end
73
+ [filtered, ambiguities]
74
+ end
75
+
76
+ private
77
+
78
+ def action_entries(candidates)
79
+ Array(candidates).flat_map do |candidate|
80
+ Array(candidate["operations"]).map(&:to_s).uniq.map do |operation|
81
+ action_descriptor(candidate, operation)
82
+ end
83
+ end
84
+ end
85
+
86
+ def bounded_visible(value)
87
+ return value unless value.is_a?(String) && value.length > VISIBLE_TEXT_CHARS
88
+
89
+ "#{value[0, VISIBLE_TEXT_CHARS]}…"
90
+ end
91
+
92
+ def collect_evidence(node, items, seen)
93
+ visible = { "role" => node["role"].to_s.downcase, "label" => node["name"].to_s,
94
+ "value" => scalar_value(node["value"]), "states" => Array(node["states"]).map(&:to_s) }
95
+ add_evidence(items, seen, visible) unless visible["role"] == "window"
96
+ Array(node["children"]).each do |child|
97
+ collect_evidence(child, items, seen) if child.is_a?(Hash)
98
+ end
99
+ end
100
+
101
+ def add_evidence(items, seen, visible)
102
+ visible = visible.compact
103
+ label = visible["label"].to_s
104
+ value = visible["value"]
105
+ return if label.empty? && (value.nil? || value.to_s.empty?)
106
+ return if seen[visible]
107
+
108
+ seen[visible] = true
109
+ items << visible
110
+ end
111
+
112
+ def scalar_value(value)
113
+ value if value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false
114
+ end
115
+ end
116
+
117
+ def initialize(scope:, snapshot:, window:, driver: "macos", read_only: false)
118
+ @scope = scope
119
+ @snapshot = snapshot
120
+ @window = window
121
+ @driver = driver
122
+ @read_only = read_only
123
+ validate!
124
+ @state = build
125
+ end
126
+
127
+ def revision = @state.fetch("revision")
128
+ def candidates = @state.fetch("candidates")
129
+
130
+ private
131
+
132
+ def validate!
133
+ unless @snapshot.is_a?(Hash) && @snapshot["snapshot_id"].is_a?(String) && @snapshot["tree"].is_a?(Hash)
134
+ raise DriverError, "Desktop observation requires a complete driver snapshot"
135
+ end
136
+ unless @window.is_a?(Hash) && @window["id"] == @scope.root &&
137
+ @window["process_instance"] == @scope.process_instance
138
+ raise ScopeLost, "The observed window is no longer the attached root surface"
139
+ end
140
+
141
+ observed_id = @snapshot.dig("window", "id")
142
+ raise ScopeLost, "The AX snapshot belongs to another window" if observed_id && observed_id != @scope.root
143
+ end
144
+
145
+ def build
146
+ observed_candidates = []
147
+ walk(@snapshot["tree"], observed_candidates)
148
+ candidates, ambiguous_actions = self.class.reject_ambiguous_actions(observed_candidates)
149
+ body = {
150
+ "schema" => "wrangle.observation.v1", "driver" => @driver,
151
+ "scope" => { "id" => @scope.id, "root" => @scope.root, "app" => @scope.app,
152
+ "pid" => @scope.pid, "process_instance" => @scope.process_instance },
153
+ "window" => @window.slice("id", "bounds", "focused", "visible"),
154
+ "snapshot_id" => @snapshot["snapshot_id"], "complete" => @snapshot.fetch("complete", true),
155
+ "tree" => @snapshot["tree"], "candidates" => candidates,
156
+ "ambiguous_actions" => ambiguous_actions,
157
+ "coverage" => coverage(candidates, ambiguous_actions)
158
+ }
159
+ body["read_only"] = true if @read_only
160
+ body["revision"] = fingerprint(body)
161
+ body
162
+ end
163
+
164
+ def walk(node, candidates)
165
+ candidate = candidate(node)
166
+ candidates << candidate if candidate
167
+ Array(node["children"]).each { |child| walk(child, candidates) if child.is_a?(Hash) }
168
+ end
169
+
170
+ def candidate(node)
171
+ ref = node["ref_id"]
172
+ return nil unless ref.is_a?(String)
173
+
174
+ role = node["role"].to_s.downcase
175
+ operations = operations(role, node)
176
+ operations &= ["DRILL"] if Array(node["states"]).map(&:to_s).include?("disabled")
177
+ operations -= ["PRESS"] if window_control?(role, node)
178
+ return nil if operations.empty?
179
+
180
+ {
181
+ "ref" => ref, "role" => role, "subrole" => node["subrole"], "label" => node["name"].to_s,
182
+ "value" => scalar(node["value"]), "states" => Array(node["states"]).map(&:to_s),
183
+ "operations" => operations, "children_count" => node["children_count"],
184
+ "target_key" => node["target_key"]
185
+ }.compact
186
+ end
187
+
188
+ def window_control?(_role, node)
189
+ subrole = node["subrole"].to_s.delete_prefix("AX").downcase
190
+ %w[closebutton minimizebutton zoombutton fullscreenbutton].include?(subrole) ||
191
+ node["name"].to_s.match?(WINDOW_CONTROL)
192
+ end
193
+
194
+ def operations(role, node)
195
+ observed = node["operations"]
196
+ return native_operations(observed, node) if observed.is_a?(Array)
197
+
198
+ offered = []
199
+ offered << "DRILL" if node["children_count"].to_i.positive?
200
+ offered.push("SET_TEXT", "CLEAR") if TEXT_ROLES.include?(role)
201
+ offered << "TOGGLE" if TOGGLE_ROLES.include?(role)
202
+ offered << "PRESS" unless role.empty? || role == "window"
203
+ offered
204
+ end
205
+
206
+ def native_operations(observed, node)
207
+ allowed = %w[PRESS SET_TEXT CLEAR TOGGLE EXPAND COLLAPSE]
208
+ offered = observed.map(&:to_s) & allowed
209
+ offered.unshift("DRILL") if node["children_count"].to_i.positive?
210
+ offered.uniq
211
+ end
212
+
213
+ def scalar(value)
214
+ value if value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false
215
+ end
216
+
217
+ def coverage(candidates, ambiguous_actions)
218
+ truncated = @snapshot["complete"] == false || truncated?(@snapshot["tree"])
219
+ provenance = Array(@snapshot["provenance"])
220
+ provenance = ["ax"] if provenance.empty?
221
+ { "provenance" => provenance, "truncated" => truncated,
222
+ "candidate_count" => candidates.length,
223
+ "ambiguous_action_count" => ambiguous_actions.sum { |action| action.fetch("matches") },
224
+ "ocr" => "not_used" }
225
+ end
226
+
227
+ def truncated?(node)
228
+ node["children_count"].to_i.positive? || Array(node["children"]).any? do |child|
229
+ child.is_a?(Hash) && truncated?(child)
230
+ end
231
+ end
232
+
233
+ def fingerprint(body)
234
+ # Non-actionable AX text can be intrinsically volatile (Finder's free-space status changes
235
+ # when the proposal log is written). Revisions bind scope, coverage, and every offered target,
236
+ # while the full tree remains available for inspection and progressive traversal.
237
+ semantic = without_refs(body.except("snapshot_id", "tree"))
238
+ Digest::SHA256.hexdigest(JSON.generate(Observation.canonical(semantic)))[0, 24]
239
+ end
240
+
241
+ def without_refs(value)
242
+ case value
243
+ when Hash
244
+ value.each_with_object({}) do |(key, child), stripped|
245
+ stripped[key] = without_refs(child) unless %w[ref ref_id].include?(key)
246
+ end
247
+ when Array then value.map { |child| without_refs(child) }
248
+ else value
249
+ end
250
+ end
251
+ end
252
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module Wrangle
6
+ # Deterministic permission policy. A provider may assess risk, but cannot make this gate weaker.
7
+ class DesktopPolicy
8
+ CREDENTIAL = /password|passcode|verification code|one[- ]time|security code|api key|secret|token/i
9
+ CONSEQUENCE = /\b(send|submit|delete|remove|trash|erase|buy|purchase|pay|confirm|save|allow|
10
+ install|quit|sign\s+out)\b/ix
11
+ CONSEQUENTIAL_OPERATIONS = %w[TOGGLE].freeze
12
+ WINDOW_CONTROL = /\A(?:close|close window|close all|quit(?: .*)?|minimize|zoom|full ?screen)\z/i
13
+ WINDOW_SUBROLES = %w[closebutton minimizebutton zoombutton fullscreenbutton].freeze
14
+
15
+ Assessment = Data.define(:classification, :consequential, :permitted, :reason) do
16
+ def to_h
17
+ { "classification" => classification, "consequential" => consequential,
18
+ "permitted" => permitted, "reason" => reason }.compact
19
+ end
20
+ end
21
+
22
+ def assess(scope:, candidate:, operation:)
23
+ label = candidate["label"].to_s
24
+ if operation == "SET_TEXT" && label.match?(CREDENTIAL)
25
+ return Assessment.new(classification: "credential_handoff", consequential: true, permitted: false,
26
+ reason: "Credentials must be entered through password-manager or SSO UI")
27
+ end
28
+ if operation == "PRESS" && window_control?(candidate, label)
29
+ return Assessment.new(classification: "scope_violation", consequential: true, permitted: false,
30
+ reason: "Wrangle never closes or manages a handed-over root window")
31
+ end
32
+
33
+ consequential = CONSEQUENTIAL_OPERATIONS.include?(operation) ||
34
+ (operation == "PRESS" && label.match?(CONSEQUENCE))
35
+ reason = "#{scope.app} action #{label.inspect} may have an external or persistent consequence" if consequential
36
+ Assessment.new(classification: consequential ? "consequential" : "standard",
37
+ consequential:, permitted: true, reason:)
38
+ end
39
+
40
+ private
41
+
42
+ def window_control?(candidate, label)
43
+ subrole = candidate["subrole"].to_s.delete_prefix("AX").downcase
44
+ WINDOW_SUBROLES.include?(subrole) || label.match?(WINDOW_CONTROL)
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wrangle
4
+ # Escalates a bounded AX skeleton to a deeper root-window observation before an autonomous action.
5
+ # The provider must re-decide from the completed state; target choices never cross this boundary.
6
+ module DesktopProgressiveObservation
7
+ private
8
+
9
+ def escalate_observation?
10
+ return false if @view
11
+
12
+ previous = current
13
+ started = monotonic
14
+ @observation = @driver.observe(@scope, skeleton: false)
15
+ @full_observation = true
16
+ @proposals.clear
17
+ fields = {
18
+ "scope_id" => @scope.id, "revision" => @observation["revision"],
19
+ "previous_revision" => previous["revision"], "complete" => @observation["complete"],
20
+ "candidates" => @observation["candidates"].length,
21
+ "ambiguous_actions" => @observation.dig("coverage", "ambiguous_action_count"),
22
+ "elapsed_ms" => elapsed_ms(started)
23
+ }
24
+ @log&.record("observe_escalated", fields)
25
+ true
26
+ end
27
+
28
+ def fresh_observation
29
+ return @driver.drill(@scope, **@view) if @view
30
+
31
+ @driver.observe(@scope, skeleton: !@full_observation)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ require_relative "desktop_observation"
6
+ require_relative "errors"
7
+
8
+ module Wrangle
9
+ # Validates and binds one typed action to one semantic desktop revision.
10
+ class DesktopProposal
11
+ TTL = 300
12
+ MAX_TEXT = 2000
13
+
14
+ def self.build(scope:, observation:, request:, policy:, created_at:)
15
+ index = request["ref"]
16
+ candidate = candidate(observation, index)
17
+ operation = request["operation"] || infer_operation(candidate)
18
+ unless candidate["operations"].include?(operation) && operation != "DRILL"
19
+ raise ArgumentError, "#{operation.inspect} was not offered for action ##{index.inspect}"
20
+ end
21
+ unless DesktopObservation.action_unambiguous?(observation["candidates"], candidate, operation)
22
+ raise ArgumentError, "#{operation.inspect} for action ##{index.inspect} matches multiple visible candidates"
23
+ end
24
+
25
+ validate_text!(operation, request["text"])
26
+ assessment = policy.assess(scope:, candidate:, operation:)
27
+ raise PolicyDenied, assessment.reason unless assessment.permitted
28
+
29
+ {
30
+ "id" => SecureRandom.hex(12), "scope_id" => scope.id, "revision" => observation["revision"],
31
+ "candidate" => candidate, "number" => index, "operation" => operation, "text" => request["text"],
32
+ "policy" => assessment.to_h, "created_at" => created_at
33
+ }
34
+ end
35
+
36
+ def self.compact(proposal)
37
+ text = proposal["text"]
38
+ {
39
+ "schema" => "wrangle.proposal.v1", "proposal_id" => proposal["id"],
40
+ "scope_id" => proposal["scope_id"], "revision" => proposal["revision"],
41
+ "action" => { "ref" => proposal["number"], "operation" => proposal["operation"],
42
+ "role" => proposal.dig("candidate", "role"),
43
+ "label" => proposal.dig("candidate", "label"),
44
+ "text" => text && { "source" => "explicit", "characters" => text.length } }.compact,
45
+ "policy" => proposal["policy"]
46
+ }
47
+ end
48
+
49
+ def self.candidate(observation, index)
50
+ candidates = observation["candidates"]
51
+ unless index.is_a?(Integer) && index.positive? && index <= candidates.length
52
+ raise ArgumentError, "No action ##{index.inspect} in the current desktop observation"
53
+ end
54
+
55
+ candidates[index - 1]
56
+ end
57
+
58
+ def self.infer_operation(candidate)
59
+ actions = candidate["operations"] - ["DRILL"]
60
+ raise ArgumentError, "Specify one of #{actions.join(", ")}" unless actions.one?
61
+
62
+ actions.first
63
+ end
64
+ private_class_method :infer_operation
65
+
66
+ def self.validate_text!(operation, text)
67
+ raise ArgumentError, "Text is valid only for SET_TEXT" if operation != "SET_TEXT" && !text.nil?
68
+ return unless operation == "SET_TEXT"
69
+ return if text.is_a?(String) && !text.empty? && text.length <= MAX_TEXT && !text.include?("\0")
70
+
71
+ raise ArgumentError, "SET_TEXT requires an exact non-empty string of at most #{MAX_TEXT} characters"
72
+ end
73
+ private_class_method :validate_text!
74
+ end
75
+ end