calmlens 0.19.0 → 0.21.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 +3 -1
- data/lib/calmlens/assets.rb +110 -100
- data/lib/calmlens/cdn.rb +197 -41
- data/lib/calmlens/error_codes.rb +80 -84
- data/lib/calmlens/errors.rb +2 -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 +19 -19
- data/lib/calmlens/lenses.rb +80 -6
- data/lib/calmlens/option_keys.rb +64 -20
- data/lib/calmlens/policies.rb +50 -18
- data/lib/calmlens/resource.rb +29 -5
- data/lib/calmlens/server_client.rb +8 -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 +722 -348
- data/lib/calmlens/testing/mock_decision.rb +5 -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 +33 -11
- data/lib/calmlens/testing.rb +40 -13
- data/lib/calmlens/transform_rules.rb +120 -0
- data/lib/calmlens/transform_schema.rb +236 -0
- data/lib/calmlens/transforms.rb +16 -0
- data/lib/calmlens/upload_mime_types.rb +51 -0
- 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 +3 -0
- data/sig/calmlens.rbs +89 -14
- data/sig/error_codes.rbs +18 -20
- data/sig/qualifiers.rbs +2 -0
- data/sig/types.rbs +326 -185
- metadata +8 -2
|
@@ -82,16 +82,17 @@ 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
|
-
'explanationComplete' => true, 'source' => 'policy', '
|
|
94
|
-
'regions' => [], 'flaggedCategories' => evaluated.reject { |_, value| action(value) == 'allow' }.map(&:first) }
|
|
94
|
+
'explanationComplete' => true, 'source' => 'policy', 'rule' => nil, 'automatedStatus' => @status,
|
|
95
|
+
'regions' => [], 'linkSafety' => nil, 'flaggedCategories' => evaluated.reject { |_, value| action(value) == 'allow' }.map(&:first) }
|
|
95
96
|
end
|
|
96
97
|
|
|
97
98
|
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,9 @@ 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
|
|
12
|
-
#
|
|
11
|
+
# the portal's project settings to those who can read them) and the project has "Recognise test
|
|
12
|
+
# inputs" turned on, so CalmLens returns that scenario instead of classifying them. The formats
|
|
13
|
+
# match the JavaScript SDK's `calmlens/test-inputs` byte for byte.
|
|
13
14
|
#
|
|
14
15
|
# A scenario is a Hash with the API's keys, as Strings or Symbols (snake_case keys are converted
|
|
15
16
|
# to camelCase like other options):
|
|
@@ -20,8 +21,14 @@ module CalmLens
|
|
|
20
21
|
# 1) and `action` (`allow`, `limit`, `age-restrict` or `block`, on every instance or none) are
|
|
21
22
|
# optional. `unassessedCategories` lists categories reported as not assessed, none with instances;
|
|
22
23
|
# `regionalActions` gives up to one `limit`, `age-restrict` or `block` action for each of
|
|
23
|
-
# {REGIONS}.
|
|
24
|
-
#
|
|
24
|
+
# {REGIONS}. `detections` (optional, `people` and `faces` each optional) sets the person and face
|
|
25
|
+
# detection results a test image reports: each list holds at most 32 entries, or is the String
|
|
26
|
+
# `'unavailable'` to report the feature as not evaluated. An entry has a `box`, `[left, top, width,
|
|
27
|
+
# height]` as Integers in basis points of the image (0 to 10000, inside it), and an optional
|
|
28
|
+
# Integer `confidence` from 0 to 100; a face may also have an `age`, `'adult'`, `'minor'` or
|
|
29
|
+
# `{ 'low' => 30, 'high' => 40 }` (Integers from 0 to 120). Values are Strings and Numerics. An
|
|
30
|
+
# absent list is left out of the signed scenario; an empty one is sent as `[]`. Anything else
|
|
31
|
+
# raises `sdk/invalid-argument`.
|
|
25
32
|
#
|
|
26
33
|
# @example
|
|
27
34
|
# code = CalmLens::TestInputs.generate_text_code('harassment.insult', test_input_key)
|
|
@@ -47,8 +54,11 @@ module CalmLens
|
|
|
47
54
|
value.each { |item| deep_freeze.call(item) } if value.is_a?(Array)
|
|
48
55
|
value.freeze
|
|
49
56
|
end
|
|
57
|
+
person = { 'box' => [2500, 1500, 5000, 8500] }
|
|
58
|
+
face = ->(age = nil) { { 'box' => [3500, 2000, 3000, 3500], **(age ? { 'age' => age } : {}) } }
|
|
50
59
|
# Ready-made scenarios (deeply frozen): a clean input, a limited one, adult images and text,
|
|
51
|
-
# regional delivery, content restricted in Germany,
|
|
60
|
+
# regional delivery, content restricted in Germany, mixed instances in one category, and test
|
|
61
|
+
# images with one person and face, nobody, an adult's face, a minor's face and a group of three.
|
|
52
62
|
PRESETS = deep_freeze.call(
|
|
53
63
|
clean: { 'instances' => [] },
|
|
54
64
|
limited: one.call('harassment', 'harassment-abuse'),
|
|
@@ -59,7 +69,15 @@ module CalmLens
|
|
|
59
69
|
{ 'region' => 'FR', 'action' => 'limit' }] },
|
|
60
70
|
germany: one.call('hate', 'hate-symbol', 'hate-nazi-symbols-public-display'),
|
|
61
71
|
mixed: { 'instances' => [{ 'category' => 'harassment', 'qualifiers' => %w[harassment-abuse harassment-documentary] },
|
|
62
|
-
{ 'category' => 'harassment', 'qualifiers' => ['harassment-threat'] }] }
|
|
72
|
+
{ 'category' => 'harassment', 'qualifiers' => ['harassment-threat'] }] },
|
|
73
|
+
one_face: { 'instances' => [], 'detections' => { 'people' => [person], 'faces' => [face.call] } },
|
|
74
|
+
no_face: { 'instances' => [], 'detections' => { 'people' => [], 'faces' => [] } },
|
|
75
|
+
adult_face: { 'instances' => [], 'detections' => { 'people' => [person], 'faces' => [face.call('adult')] } },
|
|
76
|
+
minor_face: { 'instances' => [], 'detections' => { 'people' => [person], 'faces' => [face.call('minor')] } },
|
|
77
|
+
group: { 'instances' => [], 'detections' => {
|
|
78
|
+
'people' => [500, 3600, 6700].map { |left| { 'box' => [left, 2000, 2800, 8000] } },
|
|
79
|
+
'faces' => [1200, 4300, 7400].map { |left| { 'box' => [left, 2500, 1400, 2000] } }
|
|
80
|
+
} }
|
|
63
81
|
)
|
|
64
82
|
|
|
65
83
|
HEX_WORDS = %w[zero one two three four five six seven eight nine alpha bravo charlie delta echo foxtrot].freeze
|
|
@@ -82,15 +100,16 @@ module CalmLens
|
|
|
82
100
|
when 'sexual.adult' then PRESETS[kind == 'image' ? :adult_image : :adult_text]
|
|
83
101
|
when 'hate.regional-symbol' then PRESETS[:germany]
|
|
84
102
|
when 'sexual.involving-minors' then { 'instances' => [{ 'category' => 'sexual', 'qualifiers' => ['sexual-minors'] }] }
|
|
85
|
-
when 'violence.graphic'
|
|
103
|
+
when 'violence.graphic'
|
|
104
|
+
{ 'instances' => [{ 'category' => 'violence', 'qualifiers' => %w[violence-injury violence-graphic] }] }
|
|
86
105
|
when *MockClassification::CATEGORIES then { 'instances' => [{ 'category' => signal, 'qualifiers' => [] }] }
|
|
87
106
|
else raise Internal.invalid("Unknown test signal #{signal.inspect}")
|
|
88
107
|
end
|
|
89
108
|
Marshal.load(Marshal.dump(preset))
|
|
90
109
|
end
|
|
91
110
|
|
|
92
|
-
# The signed text code for a scenario or a signal (one of {SIGNALS})
|
|
93
|
-
#
|
|
111
|
+
# The signed text code for a scenario or a signal (one of {SIGNALS}). Submit it as the whole
|
|
112
|
+
# text: CalmLens moderates text with anything else around the code as ordinary content.
|
|
94
113
|
# @param input [Hash, String] a scenario or a signal
|
|
95
114
|
# @param test_input_key [String] the project's secret test input key from the portal
|
|
96
115
|
# @return [String]
|
|
@@ -100,7 +119,9 @@ module CalmLens
|
|
|
100
119
|
end
|
|
101
120
|
|
|
102
121
|
# Paints the signed test image for a scenario or a signal (one of {SIGNALS}): a grid that
|
|
103
|
-
# survives lossy formats and resizing. Upload its `png`, or encode its `rgba` in any format
|
|
122
|
+
# survives lossy formats and resizing. Upload its `png`, or encode its `rgba` in any format, as
|
|
123
|
+
# the whole image, optionally on a plain single-colour border: CalmLens moderates an image with
|
|
124
|
+
# anything else in it as ordinary content.
|
|
104
125
|
# @param input [Hash, String] a scenario or a signal
|
|
105
126
|
# @param test_input_key [String] the project's secret test input key from the portal
|
|
106
127
|
# @return [Image]
|
|
@@ -135,11 +156,12 @@ module CalmLens
|
|
|
135
156
|
Scenario.validate(input.is_a?(String) ? for_signal(input, kind) : input)
|
|
136
157
|
end
|
|
137
158
|
|
|
159
|
+
# The signed payload is the scenario's canonical JSON.
|
|
138
160
|
def text_code(checked, test_input_key)
|
|
139
161
|
payload = [Scenario.canonical_json(checked)].pack('m0').tr('+/', '-_').delete('=')
|
|
140
162
|
raise Internal.invalid('Test scenario is too large') if payload.bytesize > 8000
|
|
141
163
|
|
|
142
|
-
"<!calmlens-test #{tag(test_input_key, "calmlens-test:
|
|
164
|
+
"<!calmlens-test #{tag(test_input_key, "calmlens-test:v2:#{payload}")} #{payload}>"
|
|
143
165
|
end
|
|
144
166
|
|
|
145
167
|
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
|
|
@@ -60,13 +63,15 @@ module CalmLens
|
|
|
60
63
|
# Simulated failures for {#fail_next_request}, besides API error codes and exceptions.
|
|
61
64
|
REQUEST_FAILURES = %w[transport-error timeout].freeze
|
|
62
65
|
# 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
|
|
66
|
+
OPERATIONS = { assets: %i[search list retrieve list_children list_timeline update create classify upload_part move
|
|
67
|
+
retry explain wait_for_decision remove_content delete batch],
|
|
65
68
|
folders: %i[list retrieve create update delete move],
|
|
66
69
|
hash_lists: %i[list retrieve create update delete list_entries add_entry remove_entry],
|
|
67
|
-
policies: %i[list retrieve create update
|
|
70
|
+
policies: %i[list retrieve retrieve_assigned create update reset upgrade set_automatic_updates delete
|
|
71
|
+
assign],
|
|
72
|
+
lenses: %i[list create retrieve update delete],
|
|
68
73
|
text: %i[classify], events: %i[list],
|
|
69
|
-
cdn: %i[serve] }.freeze
|
|
74
|
+
cdn: %i[serve sign_asset_url get_asset_info] }.freeze
|
|
70
75
|
private_constant :OPERATIONS
|
|
71
76
|
# The operation names {#fail_next_request} accepts, in their camelCase form.
|
|
72
77
|
OPERATION_NAMES = (OPERATIONS.flat_map do |resource, methods|
|
|
@@ -128,7 +133,7 @@ module CalmLens
|
|
|
128
133
|
-> { lock.synchronize { last = [(Time.now.to_f * 1000).to_i, last + 1].max } }
|
|
129
134
|
end
|
|
130
135
|
|
|
131
|
-
# @param project_id [String]
|
|
136
|
+
# @param project_id [String] `project-1` by default.
|
|
132
137
|
# @param api_key [String] a key from {.create_api_key}; the default has no grants.
|
|
133
138
|
# @param now [#call] returns the current time in epoch milliseconds. The default clock reads the
|
|
134
139
|
# system time but advances at least 1 ms per reading, so records made in a row have strictly
|
|
@@ -141,7 +146,9 @@ module CalmLens
|
|
|
141
146
|
# queue the work) and returns a status from {MockDecision::STATUSES} or a {MockDecision}. It is
|
|
142
147
|
# called on uploads (`assets.create`, `assets.classify`, `text.classify` and a final part:
|
|
143
148
|
# trigger `upload`), `assets.retry` (`retry`), `assets.explain` with `rerun: true` (`rerun`) and
|
|
144
|
-
# {#complete_processing} without a decision (`complete`). `pending` leaves the asset processing
|
|
149
|
+
# {#complete_processing} without a decision (`complete`). `pending` leaves the asset processing;
|
|
150
|
+
# for a re-run it keeps the asset's decision until {#complete_processing}, and a failed re-run
|
|
151
|
+
# keeps it.
|
|
145
152
|
# Without it, queued work other than `complete` stays `pending` and the rest is `allowed`. It
|
|
146
153
|
# runs while the mock handles a request, so it must not call the mock.
|
|
147
154
|
# @param max_retries [Integer] retries after a retryable failure; 0 by default, so a simulated
|
|
@@ -162,7 +169,8 @@ module CalmLens
|
|
|
162
169
|
super(api_key: api_key, project_id: project_id, base_url: base_url, cdn_base_url: cdn_base_url, max_retries: max_retries,
|
|
163
170
|
**options, transport: method(:receive))
|
|
164
171
|
@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,
|
|
172
|
+
now: now, create_id: create_id, moderate: moderate, api_key: api_key,
|
|
173
|
+
grants: MockServerClient.grants_of(api_key))
|
|
166
174
|
@state = @fake.state
|
|
167
175
|
# Like the API, the stand-in checks upload sizes itself, in the API's order.
|
|
168
176
|
@session.defer_upload_checks = true
|
|
@@ -174,8 +182,9 @@ module CalmLens
|
|
|
174
182
|
end
|
|
175
183
|
|
|
176
184
|
# 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
|
-
# `
|
|
185
|
+
# status changes (`purged` also removes the content), the review joins `manualReviews` with
|
|
186
|
+
# `decidedBy` the mock API key (`{ 'type' => 'api-key', 'id' => nil }`) and `applied: true`, and an
|
|
187
|
+
# `asset.<decision>` event whose `actor` is a `user` joins the events feed.
|
|
179
188
|
# @param asset_id [String]
|
|
180
189
|
# @param decision [String] `allowed`, `limited`, `age-restricted`, `blocked` or `purged`
|
|
181
190
|
# @return [Hash] the asset
|
|
@@ -193,7 +202,8 @@ module CalmLens
|
|
|
193
202
|
# @param decision [String, MockDecision, nil] a status from {MockDecision::STATUSES} or a {MockDecision}
|
|
194
203
|
# @return [Hash] the asset
|
|
195
204
|
# @raise [APIError] `asset/not-found` for an unknown asset
|
|
196
|
-
# @raise [InvalidArgumentError] when the asset isn't processing
|
|
205
|
+
# @raise [InvalidArgumentError] when the asset isn't processing or awaiting a queued re-run, or for
|
|
206
|
+
# an unknown decision
|
|
197
207
|
def complete_processing(asset_id, decision = nil)
|
|
198
208
|
@mock_lock.synchronize { @fake.complete_processing(asset_id, decision) } || raise(not_found)
|
|
199
209
|
end
|
|
@@ -228,6 +238,9 @@ module CalmLens
|
|
|
228
238
|
raise Internal.invalid('retry_after must be a whole number of seconds')
|
|
229
239
|
end
|
|
230
240
|
raise Internal.invalid('details must be a Hash') unless details.nil? || details.is_a?(Hash)
|
|
241
|
+
if !(failure.is_a?(String) && ErrorCodes::STATUSES.key?(failure)) && !(details.nil? && retry_after.nil?)
|
|
242
|
+
raise Internal.invalid('details and retry_after apply only to an API error code')
|
|
243
|
+
end
|
|
231
244
|
|
|
232
245
|
name = operation && operation.to_s.split('.').map { |part| Internal.camelize(part) }.join('.')
|
|
233
246
|
if name && !OPERATION_NAMES.include?(name)
|
|
@@ -238,6 +251,18 @@ module CalmLens
|
|
|
238
251
|
self
|
|
239
252
|
end
|
|
240
253
|
|
|
254
|
+
# Fetches a URL of the mock's CDN as a browser would, with a GET and without the API key: a signed
|
|
255
|
+
# asset URL from {CDN#sign_asset_url} (with a resource appended), or a public one from
|
|
256
|
+
# {CDN#get_asset_url}. The request is recorded like any other. Error statuses are returned, not
|
|
257
|
+
# raised.
|
|
258
|
+
# @param url [String]
|
|
259
|
+
# @return [Response] with its body read
|
|
260
|
+
def fetch_cdn(url)
|
|
261
|
+
raise Internal.invalid('url must be a String') unless url.is_a?(String)
|
|
262
|
+
|
|
263
|
+
@session.cdn('GET', url, {}, {}, authenticate: false)
|
|
264
|
+
end
|
|
265
|
+
|
|
241
266
|
# @see ServerClient#ensure_lenses
|
|
242
267
|
def ensure_lenses(options) = operation('ensureLenses') { super }
|
|
243
268
|
|
|
@@ -272,7 +297,9 @@ module CalmLens
|
|
|
272
297
|
end
|
|
273
298
|
|
|
274
299
|
# Adds an event to the mock's events feed. Fields you omit (`id`, `projectId`, `createdAt`,
|
|
275
|
-
# `severity`, `title`, `facts`, and nil `description` and `assetId`) are filled in
|
|
300
|
+
# `severity`, `title`, `facts`, and nil `description` and `assetId`) are filled in, and `actor`
|
|
301
|
+
# is the mock API key (`{ 'type' => 'api-key', 'id' => nil }`) unless you pass one, such as
|
|
302
|
+
# `actor: { type: 'user', id: nil }`. An event
|
|
276
303
|
# with the `asset_id` of a stored asset carries that asset's snapshot as it is now, unless you
|
|
277
304
|
# pass `asset`.
|
|
278
305
|
# @param event [Hash] at least `type:`; snake_case keys are converted.
|