calmlens 0.20.0 → 0.22.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.
- checksums.yaml +4 -4
- data/README.md +4 -1
- data/lib/calmlens/assets.rb +149 -110
- data/lib/calmlens/cdn.rb +197 -41
- data/lib/calmlens/error_codes.rb +100 -84
- data/lib/calmlens/errors.rb +11 -2
- data/lib/calmlens/events.rb +50 -1
- data/lib/calmlens/folders.rb +14 -9
- data/lib/calmlens/hash_lists.rb +29 -9
- data/lib/calmlens/internal.rb +9 -19
- data/lib/calmlens/lenses.rb +79 -6
- data/lib/calmlens/option_keys.rb +56 -26
- data/lib/calmlens/policies.rb +50 -18
- data/lib/calmlens/project_configuration.rb +113 -0
- data/lib/calmlens/resource.rb +24 -6
- data/lib/calmlens/server_client.rb +17 -6
- data/lib/calmlens/session.rb +29 -12
- data/lib/calmlens/testing/asset_filters.rb +39 -0
- data/lib/calmlens/testing/classification.rb +8 -2
- data/lib/calmlens/testing/fake_api.rb +950 -397
- data/lib/calmlens/testing/mock_decision.rb +6 -4
- data/lib/calmlens/testing/rsql.rb +245 -0
- data/lib/calmlens/testing/test_input_detections.rb +61 -0
- data/lib/calmlens/testing/test_input_scenario.rb +6 -2
- data/lib/calmlens/testing/test_inputs.rb +30 -10
- data/lib/calmlens/testing.rb +56 -15
- data/lib/calmlens/transform_rules.rb +1 -1
- data/lib/calmlens/upload_body.rb +85 -2
- data/lib/calmlens/upload_mime_types.rb +9 -1
- data/lib/calmlens/upload_tokens.rb +3 -3
- data/lib/calmlens/version.rb +1 -1
- data/lib/calmlens/webhooks.rb +58 -13
- data/lib/calmlens.rb +1 -0
- data/sig/calmlens.rbs +112 -13
- data/sig/error_codes.rbs +28 -20
- data/sig/qualifiers.rbs +2 -0
- data/sig/types.rbs +488 -190
- metadata +6 -2
|
@@ -82,16 +82,18 @@ module CalmLens
|
|
|
82
82
|
end
|
|
83
83
|
|
|
84
84
|
# The report a decided asset gets, as the API sends it: every category (unflagged ones
|
|
85
|
-
# evaluated with score 0), the policy's actions and `flaggedCategories`.
|
|
85
|
+
# evaluated with score 0), the policy's actions, the automated status and `flaggedCategories`.
|
|
86
86
|
# @return [Hash{String => Object}, nil] `categories`, `score` and `policy`; nil for `pending` and `failed`
|
|
87
87
|
def report
|
|
88
88
|
return if undecided?
|
|
89
89
|
|
|
90
90
|
evaluated = (MockClassification::CATEGORIES - @not_evaluated).map { |category| [category, @flags.dig(category, :score) || 0] }
|
|
91
|
-
{ 'raw' => { 'categories' => MockClassification::CATEGORIES.to_h { |category| [category, category_report(category)] }
|
|
91
|
+
{ 'raw' => { 'categories' => MockClassification::CATEGORIES.to_h { |category| [category, category_report(category)] },
|
|
92
|
+
'hashMatches' => [] },
|
|
92
93
|
'categories' => evaluated.to_h { |category, value| [category, { 'score' => value, 'action' => action(value) }] },
|
|
93
|
-
|
|
94
|
-
'
|
|
94
|
+
# The mock keeps no policies, so no policy is named.
|
|
95
|
+
'explanationComplete' => true, 'policy' => nil, 'source' => 'policy', 'rule' => nil, 'automatedStatus' => @status,
|
|
96
|
+
'regions' => [], 'linkSafety' => nil, 'flaggedCategories' => evaluated.reject { |_, value| action(value) == 'allow' }.map(&:first) }
|
|
95
97
|
end
|
|
96
98
|
|
|
97
99
|
def inspect = "#<#{self.class.name} #{@status}>"
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'asset_filters'
|
|
4
|
+
|
|
5
|
+
module CalmLens
|
|
6
|
+
module Testing
|
|
7
|
+
# Asset list filters as the API reads them (zodbase/rsql with @rsql/parser): comparisons joined
|
|
8
|
+
# by `;` or `and` (binding tighter), `,` or `or`, and grouped with parentheses. A filter names
|
|
9
|
+
# only {MockAssetFilters::FIELDS}, each with only its own operators; anything else, or
|
|
10
|
+
# bad syntax, answers 400 `filter/invalid`. A number field's value must be a number,
|
|
11
|
+
# and `null` is a missing value only on a nullable field. `*` in an `==` or `!=` text value
|
|
12
|
+
# matches any run of characters regardless of case; without it `==` is exact. As in SQL, a
|
|
13
|
+
# comparison never matches a missing value.
|
|
14
|
+
# @api private
|
|
15
|
+
module RSQL
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# A filter the API refuses, answered as `filter/invalid`.
|
|
19
|
+
class Invalid < StandardError; end
|
|
20
|
+
|
|
21
|
+
def filter(items, expression)
|
|
22
|
+
return items if expression.nil? || expression.to_s.empty?
|
|
23
|
+
|
|
24
|
+
test = Parser.new(Lexer.new(expression.to_s).tokens).parse
|
|
25
|
+
items.select { |item| test.call(item) }
|
|
26
|
+
rescue Invalid
|
|
27
|
+
raise Failure.new(ErrorCodes::INVALID_FILTER, 'Invalid filter', status: 400)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# A test for one comparison, refusing a field or operator the API doesn't accept.
|
|
31
|
+
def comparison(field, operator, raw_values)
|
|
32
|
+
rule = MockAssetFilters::FIELDS[field]
|
|
33
|
+
raise Invalid unless rule && rule[:operators].include?(operator)
|
|
34
|
+
|
|
35
|
+
values = raw_values.map { |raw| typed(rule, raw) }
|
|
36
|
+
pattern = values.first.is_a?(String) && values.first.include?('*') ? wildcard(values.first) : nil
|
|
37
|
+
lambda do |item|
|
|
38
|
+
actual = field.split('.').reduce(item) { |object, key| object.is_a?(Hash) ? object[key] : nil }
|
|
39
|
+
# A list holds any of the values (=in=) or none of them (=out=); a missing list holds none.
|
|
40
|
+
next values.any? { |value| Array(actual).include?(value) } == (operator == '=in=') if rule[:kind] == 'list'
|
|
41
|
+
|
|
42
|
+
# Only `==null` matches a missing value.
|
|
43
|
+
next operator == '==' && values.first.nil? if actual.nil?
|
|
44
|
+
|
|
45
|
+
compare([actual], operator, values, pattern)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def compare(entries, operator, values, pattern)
|
|
50
|
+
first = values.first
|
|
51
|
+
case operator
|
|
52
|
+
when '==' then pattern ? entries.any? { |entry| pattern.match?(entry.to_s) } : !first.nil? && entries.include?(first)
|
|
53
|
+
when '!=' then pattern ? entries.none? { |entry| pattern.match?(entry.to_s) } : first.nil? || !entries.include?(first)
|
|
54
|
+
when '=in=' then values.compact.any? { |value| entries.include?(value) }
|
|
55
|
+
when '=out=' then values.none?(&:nil?) && values.none? { |value| entries.include?(value) }
|
|
56
|
+
else !first.nil? && entries.any? { |entry| ordered?(entry, operator, first) }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# A value as the API reads it for the field: `null` is missing on a nullable field, a number
|
|
61
|
+
# field's value must be a finite number (as JavaScript's `Number` reads it), and text stays text.
|
|
62
|
+
def typed(rule, raw)
|
|
63
|
+
return nil if raw == 'null' && rule[:nullable]
|
|
64
|
+
return raw unless rule[:kind] == 'number'
|
|
65
|
+
|
|
66
|
+
number(raw) || raise(Invalid)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
RADIXES = { 'x' => 16, 'o' => 8, 'b' => 2 }.freeze
|
|
70
|
+
|
|
71
|
+
def number(raw)
|
|
72
|
+
text = raw.strip
|
|
73
|
+
return 0 if text.empty?
|
|
74
|
+
return Integer(text[2..], RADIXES.fetch(text[1].downcase)) if text.match?(/\A0(?:[xX]\h+|[oO][0-7]+|[bB][01]+)\z/)
|
|
75
|
+
return unless text.match?(/\A[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?\z/)
|
|
76
|
+
|
|
77
|
+
value = Float(text)
|
|
78
|
+
value.finite? ? value : nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# `*` matches any run of characters, ignoring case; every other character matches itself.
|
|
82
|
+
def wildcard(value)
|
|
83
|
+
Regexp.new("\\A#{value.split('*', -1).map { |part| Regexp.escape(part) }.join('.*')}\\z",
|
|
84
|
+
Regexp::IGNORECASE | Regexp::MULTILINE)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Numbers compare as numbers, text by code point.
|
|
88
|
+
def ordered?(entry, operator, value)
|
|
89
|
+
return false unless entry.is_a?(Numeric) == value.is_a?(Numeric)
|
|
90
|
+
|
|
91
|
+
entry.public_send(operator, value)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Tokens like @rsql/parser's lexer: quoted values (with backslash escapes), parentheses, `;`
|
|
95
|
+
# and `,` (or `and ` and `or `), comparison operators (`==`, `!=`, `<`, `<=`, `>`, `>=` and
|
|
96
|
+
# `=word=`) and unquoted runs up to a reserved character.
|
|
97
|
+
class Lexer
|
|
98
|
+
RESERVED = ['"', "'", '(', ')', ';', ',', '=', '!', '~', '<', '>', ' ', "\n", "\t", "\r"].freeze
|
|
99
|
+
WHITESPACE = [' ', "\n", "\t", "\r"].freeze
|
|
100
|
+
|
|
101
|
+
attr_reader :tokens
|
|
102
|
+
|
|
103
|
+
def initialize(input)
|
|
104
|
+
@input = input
|
|
105
|
+
@index = 0
|
|
106
|
+
@tokens = []
|
|
107
|
+
@tokens << next_token while token_ahead?
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
# Skips whitespace; true while input remains.
|
|
113
|
+
def token_ahead?
|
|
114
|
+
@index += 1 while @index < @input.length && WHITESPACE.include?(@input[@index])
|
|
115
|
+
@index < @input.length
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def next_token
|
|
119
|
+
char = @input[@index]
|
|
120
|
+
case char
|
|
121
|
+
when '"', "'" then quoted(char)
|
|
122
|
+
when '(', ')' then single(:paren, char)
|
|
123
|
+
when ';', ',' then single(:logic, char)
|
|
124
|
+
when '=', '!', '~', '<', '>' then operator
|
|
125
|
+
else verbose || unquoted
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def single(type, char)
|
|
130
|
+
@index += 1
|
|
131
|
+
[type, char]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def quoted(quote)
|
|
135
|
+
finish = @index
|
|
136
|
+
loop do
|
|
137
|
+
finish = @input.index(quote, finish + 1)
|
|
138
|
+
raise Invalid if finish.nil?
|
|
139
|
+
|
|
140
|
+
slashes = 0
|
|
141
|
+
slashes += 1 while @input[finish - 1 - slashes] == '\\' && finish - 1 - slashes > @index
|
|
142
|
+
break if slashes.even?
|
|
143
|
+
end
|
|
144
|
+
token = [:quoted, @input[(@index + 1)...finish].gsub(/\\([\s\S])/, '\1')]
|
|
145
|
+
@index = finish + 1
|
|
146
|
+
token
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def operator
|
|
150
|
+
match = @input[@index..].match(/\A(?:==|!=|<=|>=|<|>|=[a-z]*=)/)
|
|
151
|
+
raise Invalid unless match
|
|
152
|
+
|
|
153
|
+
@index += match[0].length
|
|
154
|
+
[:operator, match[0]]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def verbose
|
|
158
|
+
match = @input[@index..].match(/\A(and|or) /)
|
|
159
|
+
return unless match
|
|
160
|
+
|
|
161
|
+
@index += match[1].length
|
|
162
|
+
[:logic, match[1] == 'and' ? ';' : ',']
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def unquoted
|
|
166
|
+
finish = @index + 1
|
|
167
|
+
finish += 1 while finish < @input.length && !RESERVED.include?(@input[finish])
|
|
168
|
+
token = [:unquoted, @input[@index...finish]]
|
|
169
|
+
@index = finish
|
|
170
|
+
token
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Builds the test for a whole filter: `,` (or) of `;` (and) of comparisons and groups.
|
|
175
|
+
class Parser
|
|
176
|
+
def initialize(tokens)
|
|
177
|
+
@tokens = tokens
|
|
178
|
+
@position = 0
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def parse
|
|
182
|
+
test = disjunction
|
|
183
|
+
raise Invalid unless @position == @tokens.size
|
|
184
|
+
|
|
185
|
+
test
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
private
|
|
189
|
+
|
|
190
|
+
def peek = @tokens[@position]
|
|
191
|
+
|
|
192
|
+
def take
|
|
193
|
+
token = @tokens[@position] || raise(Invalid)
|
|
194
|
+
@position += 1
|
|
195
|
+
token
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def take_if(token) = peek == token && take
|
|
199
|
+
|
|
200
|
+
def disjunction
|
|
201
|
+
alternatives = [conjunction]
|
|
202
|
+
alternatives << conjunction while take_if([:logic, ','])
|
|
203
|
+
->(item) { alternatives.any? { |test| test.call(item) } }
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def conjunction
|
|
207
|
+
terms = [term]
|
|
208
|
+
terms << term while take_if([:logic, ';'])
|
|
209
|
+
->(item) { terms.all? { |test| test.call(item) } }
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def term
|
|
213
|
+
if take_if([:paren, '('])
|
|
214
|
+
test = disjunction
|
|
215
|
+
raise Invalid unless take == [:paren, ')']
|
|
216
|
+
|
|
217
|
+
return test
|
|
218
|
+
end
|
|
219
|
+
selector = take
|
|
220
|
+
operator = take
|
|
221
|
+
raise Invalid unless selector[0] == :unquoted && operator[0] == :operator
|
|
222
|
+
|
|
223
|
+
RSQL.comparison(selector[1], operator[1], arguments)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def arguments
|
|
227
|
+
return [value] unless take_if([:paren, '('])
|
|
228
|
+
|
|
229
|
+
values = [value]
|
|
230
|
+
values << value while take_if([:logic, ','])
|
|
231
|
+
raise Invalid unless take == [:paren, ')']
|
|
232
|
+
|
|
233
|
+
values
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def value
|
|
237
|
+
token = take
|
|
238
|
+
raise Invalid unless %i[unquoted quoted].include?(token[0])
|
|
239
|
+
|
|
240
|
+
token[1]
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'classification'
|
|
4
|
+
|
|
5
|
+
module CalmLens
|
|
6
|
+
module TestInputs
|
|
7
|
+
# Checks a test scenario's `detections` (test_input_scenario.rb).
|
|
8
|
+
# @api private
|
|
9
|
+
module Detections
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# The people and faces a test image reports, each a list of boxes or `unavailable`.
|
|
13
|
+
def validate(value)
|
|
14
|
+
value = Scenario.fields(value, 'detections', %w[people faces], [])
|
|
15
|
+
checked = {}
|
|
16
|
+
checked['people'] = list(value['people'], 'people', %w[box confidence]) if value.key?('people')
|
|
17
|
+
checked['faces'] = list(value['faces'], 'faces', %w[box confidence age]) if value.key?('faces')
|
|
18
|
+
checked
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def list(value, name, allowed)
|
|
22
|
+
return value if value == 'unavailable'
|
|
23
|
+
|
|
24
|
+
Scenario.check(value.is_a?(Array), "Detection #{name} must be an Array or 'unavailable'")
|
|
25
|
+
Scenario.array(value, name, MockClassification::TEST_DETECTIONS_MAX).map do |entry|
|
|
26
|
+
entry = Scenario.fields(entry, 'detection', allowed, %w[box])
|
|
27
|
+
checked = { 'box' => check_box(entry['box']) }
|
|
28
|
+
if entry.key?('confidence')
|
|
29
|
+
Scenario.check(integer_between?(entry['confidence'], 0, 100), 'Detection confidence must be an Integer from 0 to 100')
|
|
30
|
+
checked['confidence'] = entry['confidence']
|
|
31
|
+
end
|
|
32
|
+
checked['age'] = check_age(entry['age']) if entry.key?('age')
|
|
33
|
+
checked
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# `[left, top, width, height]` in basis points of the image, inside it.
|
|
38
|
+
def check_box(box)
|
|
39
|
+
max = MockClassification::TEST_DETECTION_BOX_MAX
|
|
40
|
+
Scenario.check(box.is_a?(Array) && box.size == 4 && box.all? { |item| integer_between?(item, 0, max) } &&
|
|
41
|
+
box[0] + box[2] <= max && box[1] + box[3] <= max,
|
|
42
|
+
"A detection box is [left, top, width, height]: Integers from 0 to #{max} inside the image")
|
|
43
|
+
box.dup
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def check_age(age)
|
|
47
|
+
return age if age.is_a?(String) && MockClassification::TEST_FACE_AGE_PRESETS.include?(age)
|
|
48
|
+
|
|
49
|
+
max = MockClassification::TEST_FACE_AGE_MAX
|
|
50
|
+
age = Scenario.fields(age, 'age', %w[low high], %w[low high]) if age.is_a?(Hash)
|
|
51
|
+
Scenario.check(age.is_a?(Hash) && integer_between?(age['low'], 0, max) && integer_between?(age['high'], age['low'], max),
|
|
52
|
+
"A face age is #{MockClassification::TEST_FACE_AGE_PRESETS.join(' or ')}, or Integers low to high from 0 to #{max}")
|
|
53
|
+
{ 'low' => age['low'], 'high' => age['high'] }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def integer_between?(value, min, max) = value.is_a?(Integer) && value.between?(min, max)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private_constant :Detections
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative 'classification'
|
|
4
|
+
require_relative 'test_input_detections'
|
|
4
5
|
|
|
5
6
|
module CalmLens
|
|
6
7
|
# The scenario checks behind the signed test inputs (test_inputs.rb).
|
|
@@ -23,6 +24,7 @@ module CalmLens
|
|
|
23
24
|
when Hash then "{#{value.map { |key, item| "\"#{key}\":#{canonical_json(item)}" }.join(',')}}"
|
|
24
25
|
when Array then "[#{value.map { |item| canonical_json(item) }.join(',')}]"
|
|
25
26
|
when Float then js_number(value)
|
|
27
|
+
when Integer then value.to_s
|
|
26
28
|
else "\"#{value}\""
|
|
27
29
|
end
|
|
28
30
|
end
|
|
@@ -49,7 +51,7 @@ module CalmLens
|
|
|
49
51
|
|
|
50
52
|
# Checks a scenario and returns it with camelCase String keys in canonical order.
|
|
51
53
|
def validate(input)
|
|
52
|
-
input = fields(input, 'scenario', %w[regionalActions unassessedCategories instances], %w[instances])
|
|
54
|
+
input = fields(input, 'scenario', %w[regionalActions unassessedCategories instances detections], %w[instances])
|
|
53
55
|
instances = array(input['instances'], 'instances', 128).map { |instance| check_instance(instance) }
|
|
54
56
|
with_action = instances.count { |instance| instance.key?('action') }
|
|
55
57
|
check([0, instances.size].include?(with_action), 'Set an action for every instance or omit all actions')
|
|
@@ -72,7 +74,9 @@ module CalmLens
|
|
|
72
74
|
end
|
|
73
75
|
checked['unassessedCategories'] = unassessed
|
|
74
76
|
end
|
|
75
|
-
checked
|
|
77
|
+
checked['instances'] = instances
|
|
78
|
+
checked['detections'] = Detections.validate(input['detections']) if input.key?('detections')
|
|
79
|
+
checked
|
|
76
80
|
end
|
|
77
81
|
|
|
78
82
|
def check_instance(instance)
|
|
@@ -8,8 +8,8 @@ require_relative 'test_image_grid'
|
|
|
8
8
|
module CalmLens
|
|
9
9
|
# Signed test inputs: text codes, test images and media phrases that a project's moderation
|
|
10
10
|
# recognises as a declared scenario when they are signed with its secret test input key (shown in
|
|
11
|
-
# the portal's project settings), so CalmLens returns that scenario instead of classifying them.
|
|
12
|
-
#
|
|
11
|
+
# the portal's project settings to those who can read them), so CalmLens returns that scenario instead of classifying them. The formats
|
|
12
|
+
# match the JavaScript SDK's `calmlens/test-inputs` byte for byte.
|
|
13
13
|
#
|
|
14
14
|
# A scenario is a Hash with the API's keys, as Strings or Symbols (snake_case keys are converted
|
|
15
15
|
# to camelCase like other options):
|
|
@@ -20,8 +20,14 @@ module CalmLens
|
|
|
20
20
|
# 1) and `action` (`allow`, `limit`, `age-restrict` or `block`, on every instance or none) are
|
|
21
21
|
# optional. `unassessedCategories` lists categories reported as not assessed, none with instances;
|
|
22
22
|
# `regionalActions` gives up to one `limit`, `age-restrict` or `block` action for each of
|
|
23
|
-
# {REGIONS}.
|
|
24
|
-
#
|
|
23
|
+
# {REGIONS}. `detections` (optional, `people` and `faces` each optional) sets the person and face
|
|
24
|
+
# detection results a test image reports: each list holds at most 32 entries, or is the String
|
|
25
|
+
# `'unavailable'` to report the feature as not evaluated. An entry has a `box`, `[left, top, width,
|
|
26
|
+
# height]` as Integers in basis points of the image (0 to 10000, inside it), and an optional
|
|
27
|
+
# Integer `confidence` from 0 to 100; a face may also have an `age`, `'adult'`, `'minor'` or
|
|
28
|
+
# `{ 'low' => 30, 'high' => 40 }` (Integers from 0 to 120). Values are Strings and Numerics. An
|
|
29
|
+
# absent list is left out of the signed scenario; an empty one is sent as `[]`. Anything else
|
|
30
|
+
# raises `sdk/invalid-argument`.
|
|
25
31
|
#
|
|
26
32
|
# @example
|
|
27
33
|
# code = CalmLens::TestInputs.generate_text_code('harassment.insult', test_input_key)
|
|
@@ -47,8 +53,11 @@ module CalmLens
|
|
|
47
53
|
value.each { |item| deep_freeze.call(item) } if value.is_a?(Array)
|
|
48
54
|
value.freeze
|
|
49
55
|
end
|
|
56
|
+
person = { 'box' => [2500, 1500, 5000, 8500] }
|
|
57
|
+
face = ->(age = nil) { { 'box' => [3500, 2000, 3000, 3500], **(age ? { 'age' => age } : {}) } }
|
|
50
58
|
# Ready-made scenarios (deeply frozen): a clean input, a limited one, adult images and text,
|
|
51
|
-
# regional delivery, content restricted in Germany,
|
|
59
|
+
# regional delivery, content restricted in Germany, mixed instances in one category, and test
|
|
60
|
+
# images with one person and face, nobody, an adult's face, a minor's face and a group of three.
|
|
52
61
|
PRESETS = deep_freeze.call(
|
|
53
62
|
clean: { 'instances' => [] },
|
|
54
63
|
limited: one.call('harassment', 'harassment-abuse'),
|
|
@@ -59,7 +68,15 @@ module CalmLens
|
|
|
59
68
|
{ 'region' => 'FR', 'action' => 'limit' }] },
|
|
60
69
|
germany: one.call('hate', 'hate-symbol', 'hate-nazi-symbols-public-display'),
|
|
61
70
|
mixed: { 'instances' => [{ 'category' => 'harassment', 'qualifiers' => %w[harassment-abuse harassment-documentary] },
|
|
62
|
-
{ 'category' => 'harassment', 'qualifiers' => ['harassment-threat'] }] }
|
|
71
|
+
{ 'category' => 'harassment', 'qualifiers' => ['harassment-threat'] }] },
|
|
72
|
+
one_face: { 'instances' => [], 'detections' => { 'people' => [person], 'faces' => [face.call] } },
|
|
73
|
+
no_face: { 'instances' => [], 'detections' => { 'people' => [], 'faces' => [] } },
|
|
74
|
+
adult_face: { 'instances' => [], 'detections' => { 'people' => [person], 'faces' => [face.call('adult')] } },
|
|
75
|
+
minor_face: { 'instances' => [], 'detections' => { 'people' => [person], 'faces' => [face.call('minor')] } },
|
|
76
|
+
group: { 'instances' => [], 'detections' => {
|
|
77
|
+
'people' => [500, 3600, 6700].map { |left| { 'box' => [left, 2000, 2800, 8000] } },
|
|
78
|
+
'faces' => [1200, 4300, 7400].map { |left| { 'box' => [left, 2500, 1400, 2000] } }
|
|
79
|
+
} }
|
|
63
80
|
)
|
|
64
81
|
|
|
65
82
|
HEX_WORDS = %w[zero one two three four five six seven eight nine alpha bravo charlie delta echo foxtrot].freeze
|
|
@@ -90,8 +107,8 @@ module CalmLens
|
|
|
90
107
|
Marshal.load(Marshal.dump(preset))
|
|
91
108
|
end
|
|
92
109
|
|
|
93
|
-
# The signed text code for a scenario or a signal (one of {SIGNALS})
|
|
94
|
-
#
|
|
110
|
+
# The signed text code for a scenario or a signal (one of {SIGNALS}). Submit it as the whole
|
|
111
|
+
# text: CalmLens moderates text with anything else around the code as ordinary content.
|
|
95
112
|
# @param input [Hash, String] a scenario or a signal
|
|
96
113
|
# @param test_input_key [String] the project's secret test input key from the portal
|
|
97
114
|
# @return [String]
|
|
@@ -101,7 +118,9 @@ module CalmLens
|
|
|
101
118
|
end
|
|
102
119
|
|
|
103
120
|
# Paints the signed test image for a scenario or a signal (one of {SIGNALS}): a grid that
|
|
104
|
-
# survives lossy formats and resizing. Upload its `png`, or encode its `rgba` in any format
|
|
121
|
+
# survives lossy formats and resizing. Upload its `png`, or encode its `rgba` in any format, as
|
|
122
|
+
# the whole image, optionally on a plain single-colour border: CalmLens moderates an image with
|
|
123
|
+
# anything else in it as ordinary content.
|
|
105
124
|
# @param input [Hash, String] a scenario or a signal
|
|
106
125
|
# @param test_input_key [String] the project's secret test input key from the portal
|
|
107
126
|
# @return [Image]
|
|
@@ -136,11 +155,12 @@ module CalmLens
|
|
|
136
155
|
Scenario.validate(input.is_a?(String) ? for_signal(input, kind) : input)
|
|
137
156
|
end
|
|
138
157
|
|
|
158
|
+
# The signed payload is the scenario's canonical JSON.
|
|
139
159
|
def text_code(checked, test_input_key)
|
|
140
160
|
payload = [Scenario.canonical_json(checked)].pack('m0').tr('+/', '-_').delete('=')
|
|
141
161
|
raise Internal.invalid('Test scenario is too large') if payload.bytesize > 8000
|
|
142
162
|
|
|
143
|
-
"<!calmlens-test #{tag(test_input_key, "calmlens-test:
|
|
163
|
+
"<!calmlens-test #{tag(test_input_key, "calmlens-test:v2:#{payload}")} #{payload}>"
|
|
144
164
|
end
|
|
145
165
|
|
|
146
166
|
def check_key(key)
|
data/lib/calmlens/testing.rb
CHANGED
|
@@ -22,9 +22,12 @@ module CalmLens
|
|
|
22
22
|
# A `purged` outcome removes the content with reason `policy`. The CDN answers like the real one:
|
|
23
23
|
# `asset/not-available` (403) for blocked, purged, failed or removed content,
|
|
24
24
|
# `viewer/age-confirmation-required` (403) for an age-restricted asset without
|
|
25
|
-
# `viewer_claims: {age_confirmed: true}`, whatever the key, and `
|
|
25
|
+
# `viewer_claims: {age_confirmed: true}`, whatever the key, and `auth/permission-denied` (403)
|
|
26
26
|
# for a private asset unless `api_key` comes from {.create_api_key} with an `assets:download`
|
|
27
|
-
# grant
|
|
27
|
+
# grant (the default key has no grants), or the URL is signed: {CDN#sign_asset_url} returns
|
|
28
|
+
# `{cdn_base_url}/assets/{project_id}/sig_…`, a readable grant that only the mock's CDN accepts,
|
|
29
|
+
# which serves `original` only when signed with `original: true`. {#fetch_cdn} loads such a URL
|
|
30
|
+
# without the API key, as a browser would. {#decide} applies a reviewer's decision, and
|
|
28
31
|
# {Assets#wait_for_decision} takes no real time: a pending asset times out at once.
|
|
29
32
|
# Filters join comparisons with `;` (and), which binds tighter than `,` (or), and group them with
|
|
30
33
|
# `( … )`; list cursors resume after the last item returned. {Assets#search} is the API's keyword
|
|
@@ -44,12 +47,26 @@ module CalmLens
|
|
|
44
47
|
#
|
|
45
48
|
# `path` is relative to `base_url` for API calls (`/projects/<id>/assets`, without `/v1`) and to
|
|
46
49
|
# `cdn_base_url` for CDN requests, with each segment decoded. `query` holds the decoded query
|
|
47
|
-
# string
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
+
# string; an upload's holds only transport values (`wait`, `contentType`, sizes and part
|
|
51
|
+
# numbers). `body` is the bytes sent (streams are read), or nil: JSON text, including a text
|
|
52
|
+
# upload (`{name, ..., content: {plainText, type}}`), an upload form, or a later part's bytes.
|
|
53
|
+
# `status` is the HTTP status the mock answered with, or nil when the request failed without a
|
|
54
|
+
# response.
|
|
50
55
|
Request = Struct.new(:method, :url, :path, :query, :headers, :body, :status, keyword_init: true) do
|
|
51
56
|
# @return [Object] the body parsed as JSON.
|
|
52
57
|
def json = body && JSON.parse(body)
|
|
58
|
+
|
|
59
|
+
# An upload form's asset fields and file: `{'options' => Hash, 'file' => String}`, the file as
|
|
60
|
+
# UTF-8 text for a `text/*` content type and binary otherwise; nil for any other body.
|
|
61
|
+
# @return [Hash, nil]
|
|
62
|
+
def form
|
|
63
|
+
type = headers.find { |name, _| name.to_s.casecmp?('Content-Type') }&.last
|
|
64
|
+
options, file = Testing::FakeAPI.upload_form(body, type)
|
|
65
|
+
return nil unless options
|
|
66
|
+
|
|
67
|
+
text = query['contentType'].to_s.downcase.start_with?('text/')
|
|
68
|
+
{ 'options' => JSON.parse(options), 'file' => text ? file.dup.force_encoding(Encoding::UTF_8) : file }
|
|
69
|
+
end
|
|
53
70
|
end
|
|
54
71
|
|
|
55
72
|
# @return [Testing::State] the stored assets, folders, content and events.
|
|
@@ -60,13 +77,18 @@ module CalmLens
|
|
|
60
77
|
# Simulated failures for {#fail_next_request}, besides API error codes and exceptions.
|
|
61
78
|
REQUEST_FAILURES = %w[transport-error timeout].freeze
|
|
62
79
|
# The methods {#fail_next_request} can target, by resource. Iterators are their list calls.
|
|
63
|
-
OPERATIONS = { assets: %i[search list retrieve list_children update create classify upload_part
|
|
64
|
-
retry explain wait_for_decision
|
|
80
|
+
OPERATIONS = { assets: %i[search list retrieve list_children list_timeline update create classify upload_part move
|
|
81
|
+
retry explain wait_for_decision remove_content delete batch],
|
|
65
82
|
folders: %i[list retrieve create update delete move],
|
|
66
83
|
hash_lists: %i[list retrieve create update delete list_entries add_entry remove_entry],
|
|
67
|
-
policies: %i[list retrieve create update
|
|
84
|
+
policies: %i[list retrieve retrieve_assigned create update reset upgrade set_automatic_updates delete
|
|
85
|
+
assign],
|
|
86
|
+
lenses: %i[list create retrieve update delete],
|
|
87
|
+
upload_presets: %i[list ensure delete],
|
|
88
|
+
project_settings: %i[get update],
|
|
89
|
+
metadata_fields: %i[list ensure delete],
|
|
68
90
|
text: %i[classify], events: %i[list],
|
|
69
|
-
cdn: %i[serve] }.freeze
|
|
91
|
+
cdn: %i[serve sign_asset_url get_asset_info] }.freeze
|
|
70
92
|
private_constant :OPERATIONS
|
|
71
93
|
# The operation names {#fail_next_request} accepts, in their camelCase form.
|
|
72
94
|
OPERATION_NAMES = (OPERATIONS.flat_map do |resource, methods|
|
|
@@ -141,7 +163,9 @@ module CalmLens
|
|
|
141
163
|
# queue the work) and returns a status from {MockDecision::STATUSES} or a {MockDecision}. It is
|
|
142
164
|
# called on uploads (`assets.create`, `assets.classify`, `text.classify` and a final part:
|
|
143
165
|
# trigger `upload`), `assets.retry` (`retry`), `assets.explain` with `rerun: true` (`rerun`) and
|
|
144
|
-
# {#complete_processing} without a decision (`complete`). `pending` leaves the asset processing
|
|
166
|
+
# {#complete_processing} without a decision (`complete`). `pending` leaves the asset processing;
|
|
167
|
+
# for a re-run it keeps the asset's decision until {#complete_processing}, and a failed re-run
|
|
168
|
+
# keeps it.
|
|
145
169
|
# Without it, queued work other than `complete` stays `pending` and the rest is `allowed`. It
|
|
146
170
|
# runs while the mock handles a request, so it must not call the mock.
|
|
147
171
|
# @param max_retries [Integer] retries after a retryable failure; 0 by default, so a simulated
|
|
@@ -162,7 +186,8 @@ module CalmLens
|
|
|
162
186
|
super(api_key: api_key, project_id: project_id, base_url: base_url, cdn_base_url: cdn_base_url, max_retries: max_retries,
|
|
163
187
|
**options, transport: method(:receive))
|
|
164
188
|
@fake = Testing::FakeAPI.new(project_id: project_id, base_url: self.base_url, cdn_base_url: self.cdn_base_url,
|
|
165
|
-
now: now, create_id: create_id, moderate: moderate,
|
|
189
|
+
now: now, create_id: create_id, moderate: moderate, api_key: api_key,
|
|
190
|
+
grants: MockServerClient.grants_of(api_key))
|
|
166
191
|
@state = @fake.state
|
|
167
192
|
# Like the API, the stand-in checks upload sizes itself, in the API's order.
|
|
168
193
|
@session.defer_upload_checks = true
|
|
@@ -174,8 +199,9 @@ module CalmLens
|
|
|
174
199
|
end
|
|
175
200
|
|
|
176
201
|
# Applies a reviewer's decision to a stored asset, as the portal or API would: the moderation
|
|
177
|
-
# status changes (`purged` also removes the content), the review joins `manualReviews
|
|
178
|
-
# `
|
|
202
|
+
# status changes (`purged` also removes the content), the review joins `manualReviews` with
|
|
203
|
+
# `decidedBy` the mock API key (`{ 'type' => 'api-key', 'id' => nil }`) and `applied: true`, and an
|
|
204
|
+
# `asset.<decision>` event whose `actor` is a `user` joins the events feed.
|
|
179
205
|
# @param asset_id [String]
|
|
180
206
|
# @param decision [String] `allowed`, `limited`, `age-restricted`, `blocked` or `purged`
|
|
181
207
|
# @return [Hash] the asset
|
|
@@ -193,7 +219,8 @@ module CalmLens
|
|
|
193
219
|
# @param decision [String, MockDecision, nil] a status from {MockDecision::STATUSES} or a {MockDecision}
|
|
194
220
|
# @return [Hash] the asset
|
|
195
221
|
# @raise [APIError] `asset/not-found` for an unknown asset
|
|
196
|
-
# @raise [InvalidArgumentError] when the asset isn't processing
|
|
222
|
+
# @raise [InvalidArgumentError] when the asset isn't processing or awaiting a queued re-run, or for
|
|
223
|
+
# an unknown decision
|
|
197
224
|
def complete_processing(asset_id, decision = nil)
|
|
198
225
|
@mock_lock.synchronize { @fake.complete_processing(asset_id, decision) } || raise(not_found)
|
|
199
226
|
end
|
|
@@ -241,6 +268,18 @@ module CalmLens
|
|
|
241
268
|
self
|
|
242
269
|
end
|
|
243
270
|
|
|
271
|
+
# Fetches a URL of the mock's CDN as a browser would, with a GET and without the API key: a signed
|
|
272
|
+
# asset URL from {CDN#sign_asset_url} (with a resource appended), or a public one from
|
|
273
|
+
# {CDN#get_asset_url}. The request is recorded like any other. Error statuses are returned, not
|
|
274
|
+
# raised.
|
|
275
|
+
# @param url [String]
|
|
276
|
+
# @return [Response] with its body read
|
|
277
|
+
def fetch_cdn(url)
|
|
278
|
+
raise Internal.invalid('url must be a String') unless url.is_a?(String)
|
|
279
|
+
|
|
280
|
+
@session.cdn('GET', url, {}, {}, authenticate: false)
|
|
281
|
+
end
|
|
282
|
+
|
|
244
283
|
# @see ServerClient#ensure_lenses
|
|
245
284
|
def ensure_lenses(options) = operation('ensureLenses') { super }
|
|
246
285
|
|
|
@@ -275,7 +314,9 @@ module CalmLens
|
|
|
275
314
|
end
|
|
276
315
|
|
|
277
316
|
# Adds an event to the mock's events feed. Fields you omit (`id`, `projectId`, `createdAt`,
|
|
278
|
-
# `severity`, `title`, `facts`, and nil `description` and `assetId`) are filled in
|
|
317
|
+
# `severity`, `title`, `facts`, and nil `description` and `assetId`) are filled in, and `actor`
|
|
318
|
+
# is the mock API key (`{ 'type' => 'api-key', 'id' => nil }`) unless you pass one, such as
|
|
319
|
+
# `actor: { type: 'user', id: nil }`. An event
|
|
279
320
|
# with the `asset_id` of a stored asset carries that asset's snapshot as it is now, unless you
|
|
280
321
|
# pass `asset`.
|
|
281
322
|
# @param event [Hash] at least `type:`; snake_case keys are converted.
|
|
@@ -58,7 +58,7 @@ module CalmLens
|
|
|
58
58
|
# Reads a CDN selector, the path text after the resource as sent (still percent-encoded), as the
|
|
59
59
|
# CDN does: an optional first `@lens`, then `key=value` options with dotted keys for nested ones (a
|
|
60
60
|
# bare key is `true`). Values are numbers, `true`, `false`, hex colours (`#` added) for colour
|
|
61
|
-
# options, or text. Raises ArgumentError on anything the CDN refuses as `
|
|
61
|
+
# options, or text. Raises ArgumentError on anything the CDN refuses as `transform/invalid`.
|
|
62
62
|
# Used by the mock CDN.
|
|
63
63
|
# @return [Array(String, Hash)] the lens and the options, each nil when absent
|
|
64
64
|
def parse_selector(selector)
|