calmlens 0.18.2 → 0.19.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 +137 -38
- data/lib/calmlens/cdn.rb +14 -7
- data/lib/calmlens/error_codes.rb +28 -2
- data/lib/calmlens/events.rb +5 -2
- data/lib/calmlens/folders.rb +9 -6
- data/lib/calmlens/hash_lists.rb +11 -8
- data/lib/calmlens/lenses.rb +5 -2
- data/lib/calmlens/option_keys.rb +66 -0
- data/lib/calmlens/policies.rb +100 -0
- data/lib/calmlens/resource.rb +26 -1
- data/lib/calmlens/server_client.rb +23 -6
- data/lib/calmlens/session.rb +6 -3
- data/lib/calmlens/testing/classification.rb +3 -0
- data/lib/calmlens/testing/fake_api.rb +138 -12
- data/lib/calmlens/testing/test_image_grid.rb +161 -0
- data/lib/calmlens/testing/test_input_scenario.rb +127 -0
- data/lib/calmlens/testing/test_inputs.rb +156 -0
- data/lib/calmlens/testing.rb +6 -1
- data/lib/calmlens/text.rb +5 -4
- data/lib/calmlens/transforms.rb +22 -29
- data/lib/calmlens/upload_body.rb +14 -0
- data/lib/calmlens/upload_tokens.rb +4 -1
- data/lib/calmlens/version.rb +1 -1
- data/lib/calmlens.rb +2 -0
- data/sig/calmlens.rbs +57 -2
- data/sig/error_codes.rbs +14 -1
- data/sig/types.rbs +157 -16
- metadata +7 -2
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'zlib'
|
|
4
|
+
|
|
5
|
+
module CalmLens
|
|
6
|
+
# The image grid behind the signed test images (test_inputs.rb).
|
|
7
|
+
module TestInputs
|
|
8
|
+
# The test image grid of packages/shared/src/TestImageGrid.ts, painted byte for byte: an odd
|
|
9
|
+
# N×N grid with an alternating black-and-white ring, two Gray-coded bits per interior cell as
|
|
10
|
+
# one of four luma levels (colour in chroma only), and Reed–Solomon parity over GF(256).
|
|
11
|
+
# @api private
|
|
12
|
+
module Grid
|
|
13
|
+
# Target luma of the four data levels.
|
|
14
|
+
LEVELS = [72, 128, 184, 240].freeze
|
|
15
|
+
SYMBOL_LEVEL = [0, 1, 3, 2].freeze
|
|
16
|
+
PARITY = 32
|
|
17
|
+
# Category colours, carried in the chroma only.
|
|
18
|
+
COLORS = {
|
|
19
|
+
'sexual' => [232, 80, 160], 'violence' => [224, 56, 64], 'self-harm' => [24, 168, 152],
|
|
20
|
+
'harassment' => [240, 128, 48], 'hate' => [144, 80, 216], 'criminal-activity' => [224, 184, 40],
|
|
21
|
+
'personal-information' => [48, 128, 224], 'profanity' => [136, 192, 48]
|
|
22
|
+
}.freeze
|
|
23
|
+
NEUTRAL = [144, 152, 160].freeze
|
|
24
|
+
|
|
25
|
+
# GF(256) with the polynomial 0x11d and generator 2.
|
|
26
|
+
EXP = Array.new(512, 0)
|
|
27
|
+
LOG = Array.new(256, 0)
|
|
28
|
+
x = 1
|
|
29
|
+
255.times do |i|
|
|
30
|
+
EXP[i] = x
|
|
31
|
+
LOG[x] = i
|
|
32
|
+
x <<= 1
|
|
33
|
+
x ^= 0x11d if x.anybits?(256)
|
|
34
|
+
end
|
|
35
|
+
(255...512).each { |i| EXP[i] = EXP[i - 255] }
|
|
36
|
+
[EXP, LOG].each(&:freeze)
|
|
37
|
+
|
|
38
|
+
module_function
|
|
39
|
+
|
|
40
|
+
def mul(left, right) = left.zero? || right.zero? ? 0 : EXP[LOG[left] + LOG[right]]
|
|
41
|
+
|
|
42
|
+
# g(x) = (x - a^0)...(x - a^31), highest coefficient first.
|
|
43
|
+
GENERATOR = PARITY.times.reduce([1]) do |g, i|
|
|
44
|
+
following = Array.new(g.size + 1, 0)
|
|
45
|
+
g.each_with_index do |coefficient, j|
|
|
46
|
+
following[j] ^= coefficient
|
|
47
|
+
following[j + 1] ^= mul(coefficient, EXP[i])
|
|
48
|
+
end
|
|
49
|
+
following
|
|
50
|
+
end.freeze
|
|
51
|
+
|
|
52
|
+
def parity(block)
|
|
53
|
+
parity = Array.new(PARITY, 0)
|
|
54
|
+
block.each do |byte|
|
|
55
|
+
factor = byte ^ parity[0]
|
|
56
|
+
parity = parity.drop(1) << 0
|
|
57
|
+
PARITY.times { |j| parity[j] ^= mul(GENERATOR[j + 1], factor) } unless factor.zero?
|
|
58
|
+
end
|
|
59
|
+
parity
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The codeword bytes a grid of `cells` holds, its block lengths and its data bytes.
|
|
63
|
+
def layout(cells)
|
|
64
|
+
capacity = (cells - 2) * (cells - 2) / 4
|
|
65
|
+
blocks = (capacity + 254) / 255
|
|
66
|
+
lengths = Array.new(blocks) { |index| (capacity / blocks) + (index < capacity % blocks ? 1 : 0) }
|
|
67
|
+
[capacity, lengths, capacity - (blocks * PARITY)]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The colour's chroma on the level's luma, scaled by the largest s/16 that keeps every
|
|
71
|
+
# channel in 0-255. Each scaled difference is truncated toward zero (not floored).
|
|
72
|
+
def pixel(colour, level)
|
|
73
|
+
target = LEVELS[level]
|
|
74
|
+
own = ((77 * colour[0]) + (150 * colour[1]) + (29 * colour[2])) >> 8
|
|
75
|
+
16.downto(0) do |scale|
|
|
76
|
+
pixel = colour.map do |channel|
|
|
77
|
+
difference = channel - own
|
|
78
|
+
magnitude = (difference.abs * scale) >> 4
|
|
79
|
+
difference.negative? ? target - magnitude : target + magnitude
|
|
80
|
+
end
|
|
81
|
+
return pixel if pixel.all? { |channel| channel.between?(0, 255) }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The data cell's colour: the palette blended between opposite corners, in integers.
|
|
86
|
+
def blend(palette, column, row, cells)
|
|
87
|
+
span = cells - 1
|
|
88
|
+
denominator = span * span
|
|
89
|
+
position = ((column * (span - row)) + ((span - column) * row)) * (palette.size - 1)
|
|
90
|
+
start = position / denominator
|
|
91
|
+
remainder = position - (start * denominator)
|
|
92
|
+
finish = palette[[start + 1, palette.size - 1].min]
|
|
93
|
+
palette[start].each_with_index.map do |channel, c|
|
|
94
|
+
((channel * (denominator - remainder)) + (finish[c] * remainder)) / denominator
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Paints `data` (a binary String) as a grid of the categories' colours: [width, RGBA String].
|
|
99
|
+
def paint(data, categories)
|
|
100
|
+
cells = (15..127).step(2).find { |size| layout(size)[2] >= data.bytesize }
|
|
101
|
+
raise Internal.invalid('Test scenario is too large for a test image') unless cells
|
|
102
|
+
|
|
103
|
+
stream = codeword_stream(data, cells)
|
|
104
|
+
palette = categories.uniq.map { |category| COLORS.fetch(category) }
|
|
105
|
+
palette = [NEUTRAL] if palette.empty?
|
|
106
|
+
pixels = Array.new(cells * cells) { |cell| [*cell_pixel(stream, palette, cell, cells), 255].pack('C4') }
|
|
107
|
+
cell_size = 2 * (512 / 2 / cells)
|
|
108
|
+
rows = Array.new(cells) { |row| pixels[row * cells, cells].map { |pixel| pixel * cell_size }.join * cell_size }
|
|
109
|
+
[cells * cell_size, rows.join.b]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The padded data with each block's parity, the blocks interleaved byte by byte and whitened, so
|
|
113
|
+
# a uniform area never reads as the all-zero codeword.
|
|
114
|
+
def codeword_stream(data, cells)
|
|
115
|
+
_, lengths, data_bytes = layout(cells)
|
|
116
|
+
padded = data.bytes + Array.new(data_bytes - data.bytesize) { |i| i.odd? ? 0x11 : 0xec }
|
|
117
|
+
offset = 0
|
|
118
|
+
codewords = lengths.map do |length|
|
|
119
|
+
block = padded[offset, length - PARITY]
|
|
120
|
+
offset += block.size
|
|
121
|
+
block + parity(block)
|
|
122
|
+
end
|
|
123
|
+
stream = Array.new(lengths.max) { |i| codewords.filter_map { |codeword| codeword[i] } }.flatten
|
|
124
|
+
stream.each_with_index.map { |byte, i| byte ^ (((i * 151) + 77) & 255) }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# A cell's RGB: the ring alternates black and white; data cells hold two bits each.
|
|
128
|
+
def cell_pixel(stream, palette, cell, cells)
|
|
129
|
+
row, column = cell.divmod(cells)
|
|
130
|
+
return [(row + column).odd? ? 255 : 0] * 3 if [row, column].any? { |at| at.zero? || at == cells - 1 }
|
|
131
|
+
|
|
132
|
+
symbol = ((row - 1) * (cells - 2)) + column - 1
|
|
133
|
+
index = symbol >> 2
|
|
134
|
+
level = index < stream.size ? SYMBOL_LEVEL[(stream[index] >> (6 - (2 * (symbol & 3)))) & 3] : 0
|
|
135
|
+
pixel(blend(palette, column, row, cells), level)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# The PNG encoding of test images.
|
|
140
|
+
# @api private
|
|
141
|
+
module Png
|
|
142
|
+
module_function
|
|
143
|
+
|
|
144
|
+
# 8-bit RGBA rows without filtering in stored deflate blocks, so every SDK writes the same bytes.
|
|
145
|
+
def png(width, height, rgba)
|
|
146
|
+
stride = width * 4
|
|
147
|
+
raw = Array.new(height) { |row| "\0".b + rgba.byteslice(row * stride, stride) }.join
|
|
148
|
+
zlib = "\x78\x01".b
|
|
149
|
+
blocks = (0...raw.bytesize).step(65_535).map { |offset| raw.byteslice(offset, 65_535) }
|
|
150
|
+
blocks.each_with_index do |block, index|
|
|
151
|
+
zlib << [index == blocks.size - 1 ? 1 : 0, block.bytesize, block.bytesize ^ 0xffff].pack('Cvv') << block
|
|
152
|
+
end
|
|
153
|
+
zlib << [Zlib.adler32(raw)].pack('N')
|
|
154
|
+
chunk = ->(type, body) { [body.bytesize].pack('N') + type.b + body + [Zlib.crc32(type.b + body)].pack('N') }
|
|
155
|
+
"\x89PNG\r\n\x1a\n".b + chunk.call('IHDR', [width, height, 8, 6, 0, 0, 0].pack('NNC5')) +
|
|
156
|
+
chunk.call('IDAT', zlib) + chunk.call('IEND', ''.b)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
private_constant :Grid, :Png
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'classification'
|
|
4
|
+
|
|
5
|
+
module CalmLens
|
|
6
|
+
# The scenario checks behind the signed test inputs (test_inputs.rb).
|
|
7
|
+
module TestInputs
|
|
8
|
+
# Checks test input scenarios and writes them as the backend's canonical JSON.
|
|
9
|
+
# @api private
|
|
10
|
+
module Scenario
|
|
11
|
+
ACTIONS = %w[allow limit age-restrict block].freeze
|
|
12
|
+
REGIONAL_ACTIONS = %w[limit age-restrict block].freeze
|
|
13
|
+
# The qualifiers a scenario may list for each category: all but those the input type sets.
|
|
14
|
+
ALLOWED_QUALIFIERS = MockClassification::QUALIFIERS.transform_values do |list|
|
|
15
|
+
(list - MockClassification::INPUT_QUALIFIERS).freeze
|
|
16
|
+
end.freeze
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# A checked scenario's JSON in the backend's key order. Every string is a validated ASCII token.
|
|
21
|
+
def canonical_json(value)
|
|
22
|
+
case value
|
|
23
|
+
when Hash then "{#{value.map { |key, item| "\"#{key}\":#{canonical_json(item)}" }.join(',')}}"
|
|
24
|
+
when Array then "[#{value.map { |item| canonical_json(item) }.join(',')}]"
|
|
25
|
+
when Float then js_number(value)
|
|
26
|
+
else "\"#{value}\""
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# A score as JavaScript's JSON.stringify writes it: the shortest round-tripping digits, in
|
|
31
|
+
# exponent form below 1e-6, and 0 for -0.
|
|
32
|
+
def js_number(value)
|
|
33
|
+
return '0' if value.zero?
|
|
34
|
+
|
|
35
|
+
mantissa, exponent = value.to_s.split('e')
|
|
36
|
+
whole, fraction = mantissa.split('.')
|
|
37
|
+
digits = whole + fraction.to_s
|
|
38
|
+
point = whole.size + exponent.to_i
|
|
39
|
+
stripped = digits.sub(/\A0+/, '')
|
|
40
|
+
point -= digits.size - stripped.size
|
|
41
|
+
digits = stripped.sub(/0+\z/, '')
|
|
42
|
+
if value < 1e-6
|
|
43
|
+
"#{digits[0]}#{".#{digits[1..]}" if digits.size > 1}e#{point - 1}"
|
|
44
|
+
elsif point <= 0 then "0.#{'0' * -point}#{digits}"
|
|
45
|
+
elsif point >= digits.size then digits + ('0' * (point - digits.size))
|
|
46
|
+
else "#{digits[0, point]}.#{digits[point..]}"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Checks a scenario and returns it with camelCase String keys in canonical order.
|
|
51
|
+
def validate(input)
|
|
52
|
+
input = fields(input, 'scenario', %w[regionalActions unassessedCategories instances], %w[instances])
|
|
53
|
+
instances = array(input['instances'], 'instances', 128).map { |instance| check_instance(instance) }
|
|
54
|
+
with_action = instances.count { |instance| instance.key?('action') }
|
|
55
|
+
check([0, instances.size].include?(with_action), 'Set an action for every instance or omit all actions')
|
|
56
|
+
checked = {}
|
|
57
|
+
if input.key?('regionalActions')
|
|
58
|
+
checked['regionalActions'] = array(input['regionalActions'], 'regionalActions', REGIONS.size).each_with_object([]) do |item, done|
|
|
59
|
+
item = fields(item, 'regional action', %w[region action], %w[region action])
|
|
60
|
+
check(REGIONS.include?(item['region']) && done.none? { |other| other['region'] == item['region'] },
|
|
61
|
+
"Regional actions need unique regions from #{REGIONS.join(', ')} (got #{item['region'].inspect})")
|
|
62
|
+
check(REGIONAL_ACTIONS.include?(item['action']), "Regional action must be one of #{REGIONAL_ACTIONS.join(', ')}")
|
|
63
|
+
done << { 'region' => item['region'], 'action' => item['action'] }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
if input.key?('unassessedCategories')
|
|
67
|
+
unassessed = array(input['unassessedCategories'], 'unassessedCategories', MockClassification::CATEGORIES.size)
|
|
68
|
+
unassessed.each_with_index do |category, index|
|
|
69
|
+
check(MockClassification::CATEGORIES.include?(category) && !unassessed[0, index].include?(category) &&
|
|
70
|
+
instances.none? { |instance| instance['category'] == category },
|
|
71
|
+
"Unassessed categories must be unique categories without instances (got #{category.inspect})")
|
|
72
|
+
end
|
|
73
|
+
checked['unassessedCategories'] = unassessed
|
|
74
|
+
end
|
|
75
|
+
checked.merge('instances' => instances)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def check_instance(instance)
|
|
79
|
+
instance = fields(instance, 'instance', %w[category score action qualifiers], %w[category qualifiers])
|
|
80
|
+
category = instance['category']
|
|
81
|
+
allowed = ALLOWED_QUALIFIERS[category] if category.is_a?(String)
|
|
82
|
+
check(allowed, "Unknown category #{category.inspect}")
|
|
83
|
+
checked = { 'category' => category }
|
|
84
|
+
if instance.key?('score')
|
|
85
|
+
score = instance['score']
|
|
86
|
+
check(score.is_a?(Numeric) && score.real? && score.to_f.between?(0, 1), 'Score must be a number from 0 to 1')
|
|
87
|
+
checked['score'] = score.to_f
|
|
88
|
+
end
|
|
89
|
+
if instance.key?('action')
|
|
90
|
+
check(ACTIONS.include?(instance['action']), "Action must be one of #{ACTIONS.join(', ')}")
|
|
91
|
+
checked['action'] = instance['action']
|
|
92
|
+
end
|
|
93
|
+
qualifiers = array(instance['qualifiers'], 'qualifiers', 64)
|
|
94
|
+
qualifiers.each_with_index do |qualifier, index|
|
|
95
|
+
check(allowed.include?(qualifier) && !qualifiers[0, index].include?(qualifier),
|
|
96
|
+
"Choose unique #{category} qualifiers; input-type qualifiers are automatic (got #{qualifier.inspect})")
|
|
97
|
+
end
|
|
98
|
+
checked.merge('qualifiers' => qualifiers)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# A Hash's entries by camelCase key, refusing unknown, duplicate and missing keys.
|
|
102
|
+
def fields(value, name, allowed, required)
|
|
103
|
+
check(value.is_a?(Hash), "A test #{name} must be a Hash (got #{value.class})")
|
|
104
|
+
result = {}
|
|
105
|
+
value.each do |key, item|
|
|
106
|
+
field = Internal.camelize(key) if key.is_a?(String) || key.is_a?(Symbol)
|
|
107
|
+
check(allowed.include?(field), "Unknown test #{name} field #{key.inspect}")
|
|
108
|
+
check(!result.key?(field), "Test #{name} field #{field} is given twice")
|
|
109
|
+
result[field] = item
|
|
110
|
+
end
|
|
111
|
+
missing = required - result.keys
|
|
112
|
+
check(missing.empty?, "A test #{name} needs #{missing.join(', ')}")
|
|
113
|
+
result
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def array(value, name, max)
|
|
117
|
+
check(value.is_a?(Array), "#{name} must be an Array")
|
|
118
|
+
check(value.size <= max, "#{name} has at most #{max} items")
|
|
119
|
+
value.dup
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def check(condition, message) = condition || raise(Internal.invalid(message))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private_constant :Scenario
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
require_relative 'classification'
|
|
5
|
+
require_relative 'test_input_scenario'
|
|
6
|
+
require_relative 'test_image_grid'
|
|
7
|
+
|
|
8
|
+
module CalmLens
|
|
9
|
+
# Signed test inputs: text codes, test images and media phrases that a project's moderation
|
|
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
|
+
# The formats match the JavaScript SDK's `calmlens/test-inputs` byte for byte.
|
|
13
|
+
#
|
|
14
|
+
# A scenario is a Hash with the API's keys, as Strings or Symbols (snake_case keys are converted
|
|
15
|
+
# to camelCase like other options):
|
|
16
|
+
# `{ 'regionalActions' => [{ 'region' => 'DE', 'action' => 'block' }], 'unassessedCategories' => ['profanity'],
|
|
17
|
+
# 'instances' => [{ 'category' => 'harassment', 'score' => 0.9, 'action' => 'block', 'qualifiers' => ['harassment-abuse'] }] }`.
|
|
18
|
+
# `instances` (at most 128) and each instance's `qualifiers` (unique qualifiers of its category, at
|
|
19
|
+
# most 64; input-type qualifiers such as `sexual-text` are automatic) are required; `score` (0 to
|
|
20
|
+
# 1) and `action` (`allow`, `limit`, `age-restrict` or `block`, on every instance or none) are
|
|
21
|
+
# optional. `unassessedCategories` lists categories reported as not assessed, none with instances;
|
|
22
|
+
# `regionalActions` gives up to one `limit`, `age-restrict` or `block` action for each of
|
|
23
|
+
# {REGIONS}. Values are Strings and Numerics. An absent list is left out of the signed scenario;
|
|
24
|
+
# an empty one is sent as `[]`. Anything else raises `sdk/invalid-argument`.
|
|
25
|
+
#
|
|
26
|
+
# @example
|
|
27
|
+
# code = CalmLens::TestInputs.generate_text_code('harassment.insult', test_input_key)
|
|
28
|
+
# png = CalmLens::TestInputs.paint_image('violence', test_input_key).png
|
|
29
|
+
# phrase = CalmLens::TestInputs.generate_media_code('violence', test_input_key)
|
|
30
|
+
module TestInputs
|
|
31
|
+
# A painted test image: `width` by `height` pixels of 8-bit RGBA, row by row, in the binary
|
|
32
|
+
# String `rgba`, and the same image as binary PNG bytes in `png`.
|
|
33
|
+
Image = Struct.new(:width, :height, :rgba, :png, keyword_init: true)
|
|
34
|
+
|
|
35
|
+
# The regions a regional action can name.
|
|
36
|
+
REGIONS = %w[DE AT FR GB EU AU BR].freeze
|
|
37
|
+
# The signals {generate_text_code}, {paint_image} and {for_signal} accept: the categories and
|
|
38
|
+
# five named shortcuts.
|
|
39
|
+
SIGNALS = (MockClassification::CATEGORIES +
|
|
40
|
+
%w[sexual.involving-minors harassment.insult sexual.adult hate.regional-symbol violence.graphic]).freeze
|
|
41
|
+
# The signals {generate_media_code} accepts.
|
|
42
|
+
MEDIA_SIGNALS = (MockClassification::CATEGORIES + %w[violence.graphic]).freeze
|
|
43
|
+
|
|
44
|
+
one = ->(category, *qualifiers) { { 'instances' => [{ 'category' => category, 'qualifiers' => qualifiers }] } }
|
|
45
|
+
deep_freeze = lambda do |value|
|
|
46
|
+
value.each_value { |item| deep_freeze.call(item) } if value.is_a?(Hash)
|
|
47
|
+
value.each { |item| deep_freeze.call(item) } if value.is_a?(Array)
|
|
48
|
+
value.freeze
|
|
49
|
+
end
|
|
50
|
+
# Ready-made scenarios (deeply frozen): a clean input, a limited one, adult images and text,
|
|
51
|
+
# regional delivery, content restricted in Germany, and mixed instances in one category.
|
|
52
|
+
PRESETS = deep_freeze.call(
|
|
53
|
+
clean: { 'instances' => [] },
|
|
54
|
+
limited: one.call('harassment', 'harassment-abuse'),
|
|
55
|
+
adult_image: one.call('sexual', 'sexual-pornographic-media'),
|
|
56
|
+
adult_text: one.call('sexual', 'sexual-explicit-language'),
|
|
57
|
+
regional_delivery: { 'instances' => [], 'regionalActions' => [{ 'region' => 'DE', 'action' => 'block' },
|
|
58
|
+
{ 'region' => 'GB', 'action' => 'age-restrict' },
|
|
59
|
+
{ 'region' => 'FR', 'action' => 'limit' }] },
|
|
60
|
+
germany: one.call('hate', 'hate-symbol', 'hate-nazi-symbols-public-display'),
|
|
61
|
+
mixed: { 'instances' => [{ 'category' => 'harassment', 'qualifiers' => %w[harassment-abuse harassment-documentary] },
|
|
62
|
+
{ 'category' => 'harassment', 'qualifiers' => ['harassment-threat'] }] }
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
HEX_WORDS = %w[zero one two three four five six seven eight nine alpha bravo charlie delta echo foxtrot].freeze
|
|
66
|
+
private_constant :HEX_WORDS
|
|
67
|
+
|
|
68
|
+
module_function
|
|
69
|
+
|
|
70
|
+
# The scenario a signal (one of {SIGNALS}) stands for in a text input (`kind` `'text'`) or an
|
|
71
|
+
# image (`'image'`), as a new Hash. {generate_text_code} uses the text kind and {paint_image}
|
|
72
|
+
# the image kind.
|
|
73
|
+
# @param signal [String]
|
|
74
|
+
# @param kind [String] `'text'` or `'image'`
|
|
75
|
+
# @return [Hash{String => Object}]
|
|
76
|
+
# @raise [InvalidArgumentError] `sdk/invalid-argument` for an unknown signal or kind
|
|
77
|
+
def for_signal(signal, kind = 'text')
|
|
78
|
+
raise Internal.invalid("Test input kind must be text or image (got #{kind.inspect})") unless %w[text image].include?(kind)
|
|
79
|
+
|
|
80
|
+
preset = case signal
|
|
81
|
+
when 'harassment.insult' then PRESETS[:limited]
|
|
82
|
+
when 'sexual.adult' then PRESETS[kind == 'image' ? :adult_image : :adult_text]
|
|
83
|
+
when 'hate.regional-symbol' then PRESETS[:germany]
|
|
84
|
+
when 'sexual.involving-minors' then { 'instances' => [{ 'category' => 'sexual', 'qualifiers' => ['sexual-minors'] }] }
|
|
85
|
+
when 'violence.graphic' then { 'instances' => [{ 'category' => 'violence', 'qualifiers' => ['violence-graphic'] }] }
|
|
86
|
+
when *MockClassification::CATEGORIES then { 'instances' => [{ 'category' => signal, 'qualifiers' => [] }] }
|
|
87
|
+
else raise Internal.invalid("Unknown test signal #{signal.inspect}")
|
|
88
|
+
end
|
|
89
|
+
Marshal.load(Marshal.dump(preset))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The signed text code for a scenario or a signal (one of {SIGNALS}): put it anywhere in a text
|
|
93
|
+
# input.
|
|
94
|
+
# @param input [Hash, String] a scenario or a signal
|
|
95
|
+
# @param test_input_key [String] the project's secret test input key from the portal
|
|
96
|
+
# @return [String]
|
|
97
|
+
# @raise [InvalidArgumentError] `sdk/invalid-argument` for an invalid key, scenario or signal
|
|
98
|
+
def generate_text_code(input, test_input_key)
|
|
99
|
+
text_code(scenario(input, 'text', test_input_key), test_input_key)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# 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.
|
|
104
|
+
# @param input [Hash, String] a scenario or a signal
|
|
105
|
+
# @param test_input_key [String] the project's secret test input key from the portal
|
|
106
|
+
# @return [Image]
|
|
107
|
+
# @raise [InvalidArgumentError] `sdk/invalid-argument` for an invalid key, scenario or signal, or
|
|
108
|
+
# a scenario too large for a test image
|
|
109
|
+
def paint_image(input, test_input_key)
|
|
110
|
+
checked = scenario(input, 'image', test_input_key)
|
|
111
|
+
signature = text_code(checked, test_input_key).split[1]
|
|
112
|
+
content = Scenario.canonical_json(checked).b
|
|
113
|
+
data = [content.bytesize].pack('n') + [signature].pack('H*') + content
|
|
114
|
+
width, rgba = Grid.paint(data, checked['instances'].map { |instance| instance['category'] })
|
|
115
|
+
Image.new(width: width, height: width, rgba: rgba, png: Png.png(width, width, rgba))
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The phrase that triggers a signal (one of {MEDIA_SIGNALS}) in audio or video: read it aloud,
|
|
119
|
+
# or show it as large, high-contrast text in a frame.
|
|
120
|
+
# @param signal [String]
|
|
121
|
+
# @param test_input_key [String] the project's secret test input key from the portal
|
|
122
|
+
# @return [String]
|
|
123
|
+
# @raise [InvalidArgumentError] `sdk/invalid-argument` for an invalid key or signal
|
|
124
|
+
def generate_media_code(signal, test_input_key)
|
|
125
|
+
raise Internal.invalid("Unsupported media test signal #{signal.inspect}") unless MEDIA_SIGNALS.include?(signal)
|
|
126
|
+
|
|
127
|
+
check_key(test_input_key)
|
|
128
|
+
words = tag(test_input_key, "calmlens-test:media:#{signal}").each_char.map { |digit| HEX_WORDS[digit.to_i(16)] }
|
|
129
|
+
['Calm Lens test', *words, signal.tr('.-', ' '), 'end test'].join(' ')
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The checked scenario for an input or signal, in canonical form.
|
|
133
|
+
def scenario(input, kind, test_input_key)
|
|
134
|
+
check_key(test_input_key)
|
|
135
|
+
Scenario.validate(input.is_a?(String) ? for_signal(input, kind) : input)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def text_code(checked, test_input_key)
|
|
139
|
+
payload = [Scenario.canonical_json(checked)].pack('m0').tr('+/', '-_').delete('=')
|
|
140
|
+
raise Internal.invalid('Test scenario is too large') if payload.bytesize > 8000
|
|
141
|
+
|
|
142
|
+
"<!calmlens-test #{tag(test_input_key, "calmlens-test:v1:#{payload}")} #{payload}>"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def check_key(key)
|
|
146
|
+
return if key.is_a?(String) && key.match?(/\A[a-f0-9]{32}\z/)
|
|
147
|
+
|
|
148
|
+
raise Internal.invalid('The test input key must be 32 lowercase hexadecimal characters')
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# The first 32 hex characters of HMAC-SHA256 with the key's ASCII bytes.
|
|
152
|
+
def tag(key, message) = OpenSSL::HMAC.hexdigest('SHA256', key, message)[0, 32]
|
|
153
|
+
|
|
154
|
+
private_class_method :scenario, :text_code, :check_key, :tag
|
|
155
|
+
end
|
|
156
|
+
end
|
data/lib/calmlens/testing.rb
CHANGED
|
@@ -5,6 +5,7 @@ require 'securerandom'
|
|
|
5
5
|
require 'uri'
|
|
6
6
|
require_relative '../calmlens'
|
|
7
7
|
require_relative 'testing/fake_api'
|
|
8
|
+
require_relative 'testing/test_inputs'
|
|
8
9
|
|
|
9
10
|
module CalmLens
|
|
10
11
|
# An offline client for tests. Calls run the real client's validation, encoding, retries and
|
|
@@ -63,6 +64,7 @@ module CalmLens
|
|
|
63
64
|
retry explain wait_for_decision delete_content delete batch],
|
|
64
65
|
folders: %i[list retrieve create update delete move],
|
|
65
66
|
hash_lists: %i[list retrieve create update delete list_entries add_entry remove_entry],
|
|
67
|
+
policies: %i[list retrieve create update apply_preset set_automatic_updates delete assign],
|
|
66
68
|
text: %i[classify], events: %i[list],
|
|
67
69
|
cdn: %i[serve] }.freeze
|
|
68
70
|
private_constant :OPERATIONS
|
|
@@ -144,7 +146,8 @@ module CalmLens
|
|
|
144
146
|
# runs while the mock handles a request, so it must not call the mock.
|
|
145
147
|
# @param max_retries [Integer] retries after a retryable failure; 0 by default, so a simulated
|
|
146
148
|
# failure reaches the caller.
|
|
147
|
-
# @param options [Hash] other {ServerClient#initialize} settings,
|
|
149
|
+
# @param options [Hash] other {ServerClient#initialize} settings, such as `part_size_bytes`, except
|
|
150
|
+
# `api_key` and `transport`.
|
|
148
151
|
# Retries and decision checks wait no real time.
|
|
149
152
|
def initialize(project_id: 'project-1', api_key: MockServerClient.create_api_key, base_url: 'https://api.calmlens.invalid/v1',
|
|
150
153
|
cdn_base_url: 'https://cdn.calmlens.invalid/v1', now: MockServerClient.monotonic_clock,
|
|
@@ -338,6 +341,8 @@ module CalmLens
|
|
|
338
341
|
path, query = Internal.split_url(request.url).last.split('?', 2)
|
|
339
342
|
base = request.url.start_with?("#{cdn_base_url}/") ? URI(cdn_base_url).path : URI(base_url).path
|
|
340
343
|
call = Thread.current[@call_key]
|
|
344
|
+
# The later parts of a content upload sent in parts count as assets.uploadPart.
|
|
345
|
+
call = { operation: 'assets.uploadPart' } if request.method == 'POST' && path.delete_prefix(base).match?(%r{\A/assets/[^/]+/part\z})
|
|
341
346
|
@mock_lock.synchronize do
|
|
342
347
|
record = Request.new(method: request.method, url: request.url, path: decode_path(path.delete_prefix(base)),
|
|
343
348
|
query: URI.decode_www_form(query.to_s).to_h, headers: request.headers.dup, body: body)
|
data/lib/calmlens/text.rb
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
module CalmLens
|
|
4
4
|
# Text moderation shortcuts. Reach it through {ServerClient#text}.
|
|
5
5
|
class Text < Resource
|
|
6
|
+
# The prefix of this resource's operations in Internal::OPTION_KEYS.
|
|
7
|
+
OPERATIONS = 'text'
|
|
8
|
+
|
|
6
9
|
# @api private
|
|
7
10
|
def initialize(session, assets)
|
|
8
11
|
super(session)
|
|
@@ -17,11 +20,9 @@ module CalmLens
|
|
|
17
20
|
# @return [Hash] the asset; show it to everyone only when `classification.moderation.status` is
|
|
18
21
|
# `allowed` or `limited` (reduce the reach of `limited` content).
|
|
19
22
|
def classify(options)
|
|
20
|
-
api, request = call(options) { |*split| split }
|
|
23
|
+
api, request = call(options, __method__) { |*split| split }
|
|
21
24
|
text = api['text']
|
|
22
|
-
unless text.is_a?(String) && !text.strip.empty?
|
|
23
|
-
raise Internal.invalid('Text to classify must be a non-empty string', ErrorCodes::INVALID_UPLOAD_PAYLOAD)
|
|
24
|
-
end
|
|
25
|
+
raise Internal.invalid('Text to classify must be a non-empty string') unless text.is_a?(String) && !text.strip.empty?
|
|
25
26
|
|
|
26
27
|
@assets.classify(without(api, 'text').merge('name' => api.fetch('name', 'text'),
|
|
27
28
|
'content' => { 'plainText' => text, 'type' => 'text/plain' },
|
data/lib/calmlens/transforms.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module CalmLens
|
|
4
|
-
# CDN transform selectors: selector Strings and structured
|
|
4
|
+
# CDN transform selectors: selector Strings and structured options serialized the way every SDK does.
|
|
5
5
|
# @api private
|
|
6
6
|
module Transforms
|
|
7
7
|
module_function
|
|
@@ -14,46 +14,39 @@ module CalmLens
|
|
|
14
14
|
# </generated: image-transform-colours>
|
|
15
15
|
HEX_COLOUR = /\A#((?:\h{3}){1,2}|(?:\h{4}){1,2})\z/
|
|
16
16
|
|
|
17
|
-
# Returns the selector the API and CDN accept for a transform: a selector String
|
|
18
|
-
# structured
|
|
17
|
+
# Returns the selector the API and CDN accept for a transform: a selector String checked and
|
|
18
|
+
# unchanged, or structured options (`{width: 640, fit: 'cover'}`) serialized by {serialize}.
|
|
19
19
|
def selector(value)
|
|
20
|
-
value.nil? || value.is_a?(String)
|
|
20
|
+
return serialize(value) unless value.nil? || value.is_a?(String)
|
|
21
|
+
# A transform is one path segment of options: it must not add a segment, lens, query or fragment.
|
|
22
|
+
if value&.match?(%r{[/?#]}) || value&.include?('..') || value&.start_with?('@')
|
|
23
|
+
raise Internal.invalid('Transform cannot contain "/", "..", "?" or "#", or start with "@"')
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
value
|
|
21
27
|
end
|
|
22
28
|
|
|
23
|
-
# Builds the CDN path of a transform. A selector String
|
|
24
|
-
#
|
|
29
|
+
# Builds the CDN path text of a transform. A selector String is encoded like JavaScript's
|
|
30
|
+
# encodeURI; serialized options are already path-safe.
|
|
25
31
|
def path(value)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
text = value.sub(%r{\A/+}, '')
|
|
29
|
-
raise Internal.invalid('Transform cannot contain "..", "?" or "#"') if text.include?('..') || text.match?(/[?#]/)
|
|
32
|
+
text = selector(value)
|
|
33
|
+
return text unless value.is_a?(String)
|
|
30
34
|
|
|
31
35
|
# Like JavaScript's encodeURI.
|
|
32
36
|
text.b.gsub(%r{[^A-Za-z0-9\-_.!~*'();/:@&=+$,]}n) { |byte| format('%%%02X', byte.ord) }
|
|
33
37
|
end
|
|
34
38
|
|
|
35
|
-
# Serializes structured
|
|
36
|
-
#
|
|
37
|
-
#
|
|
39
|
+
# Serializes structured transform options as a CDN selector, such as `fit=cover,width=640`. Option
|
|
40
|
+
# keys are sorted so equal transforms share a URL, nested keys are dotted (`border.color`), hex
|
|
41
|
+
# colours are written without `#` and values are percent-encoded.
|
|
38
42
|
def serialize(value)
|
|
39
|
-
|
|
40
|
-
raise Internal.invalid('Transform needs at least one step') if steps.empty?
|
|
41
|
-
|
|
42
|
-
steps.map do |step|
|
|
43
|
-
raise Internal.invalid('Transform steps must be Hashes') unless step.is_a?(Hash)
|
|
44
|
-
|
|
45
|
-
step = step.transform_keys(&:to_s)
|
|
46
|
-
raise Internal.invalid('Transform type must be "imageTransform"') unless step['type'] == 'imageTransform'
|
|
47
|
-
|
|
48
|
-
options = step['options'] || {}
|
|
49
|
-
raise Internal.invalid('Transform options must be a Hash') unless options.is_a?(Hash)
|
|
43
|
+
raise Internal.invalid('Transform options must be a Hash') unless value.is_a?(Hash)
|
|
50
44
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
45
|
+
pairs = []
|
|
46
|
+
sorted_options(value).each { |key, item| append_pairs(pairs, key, item) }
|
|
47
|
+
raise Internal.invalid('A transform needs at least one option') if pairs.empty?
|
|
54
48
|
|
|
55
|
-
|
|
56
|
-
end.join('/')
|
|
49
|
+
pairs.join(',')
|
|
57
50
|
end
|
|
58
51
|
|
|
59
52
|
def sorted_options(options, prefix = '')
|
data/lib/calmlens/upload_body.rb
CHANGED
|
@@ -90,6 +90,14 @@ module CalmLens
|
|
|
90
90
|
@source.rewind unless @source.is_a?(String)
|
|
91
91
|
end
|
|
92
92
|
|
|
93
|
+
# `length` bytes from `offset` as a body of their own, read again from the source for each
|
|
94
|
+
# attempt. Only for a {#replayable?} body of known size: an IO is positioned at the slice.
|
|
95
|
+
def slice(offset, length)
|
|
96
|
+
return UploadBody.new(@source.byteslice(offset, length)) if @source.is_a?(String)
|
|
97
|
+
|
|
98
|
+
UploadBody.new(@source.slice(offset, length))
|
|
99
|
+
end
|
|
100
|
+
|
|
93
101
|
# The bytes, read fully. Used only by the offline mock.
|
|
94
102
|
def read_all
|
|
95
103
|
return @source if @source.is_a?(String)
|
|
@@ -114,6 +122,12 @@ module CalmLens
|
|
|
114
122
|
|
|
115
123
|
def rewindable? = !@start.nil?
|
|
116
124
|
|
|
125
|
+
# A reader of `length` bytes from `offset` of this rewindable stream, positioned there.
|
|
126
|
+
def slice(offset, length)
|
|
127
|
+
@io.seek(@start + offset)
|
|
128
|
+
IOReader.new(@io, length)
|
|
129
|
+
end
|
|
130
|
+
|
|
117
131
|
def rewind
|
|
118
132
|
@io.seek(@start)
|
|
119
133
|
@remaining = @size
|
|
@@ -9,6 +9,9 @@ module CalmLens
|
|
|
9
9
|
# Signs upload tokens, which let a browser upload through an upload preset. Reach it through
|
|
10
10
|
# {ServerClient#upload_tokens}. Signing happens locally with the API key; no request is made.
|
|
11
11
|
class UploadTokens < Resource
|
|
12
|
+
# The prefix of this resource's operations in Internal::OPTION_KEYS.
|
|
13
|
+
OPERATIONS = 'uploadTokens'
|
|
14
|
+
|
|
12
15
|
TOKEN_PREFIX = 'clut_'
|
|
13
16
|
SIGNING_CONTEXT = 'calmlens-upload-token:v1:'
|
|
14
17
|
DEFAULT_TTL_SECONDS = 600
|
|
@@ -26,7 +29,7 @@ module CalmLens
|
|
|
26
29
|
# @option options [Integer] :expires_in_seconds lifetime, 1 to 3600 (default 600)
|
|
27
30
|
# @return [Hash] `{'token' => String, 'expiresAt' => Integer}` (milliseconds since the Unix epoch)
|
|
28
31
|
def sign(options)
|
|
29
|
-
call(options) { UploadTokens.sign_at(@api_key, options, now: (Time.now.to_f * 1000).to_i, nonce: SecureRandom.uuid) }
|
|
32
|
+
call(options, __method__) { UploadTokens.sign_at(@api_key, options, now: (Time.now.to_f * 1000).to_i, nonce: SecureRandom.uuid) }
|
|
30
33
|
end
|
|
31
34
|
|
|
32
35
|
# Signs like {#sign} at a fixed time with a fixed nonce. {MockServerClient.sign_upload_token}
|
data/lib/calmlens/version.rb
CHANGED
data/lib/calmlens.rb
CHANGED
|
@@ -8,6 +8,7 @@ end
|
|
|
8
8
|
require_relative 'calmlens/version'
|
|
9
9
|
require_relative 'calmlens/error_codes'
|
|
10
10
|
require_relative 'calmlens/errors'
|
|
11
|
+
require_relative 'calmlens/option_keys'
|
|
11
12
|
require_relative 'calmlens/internal'
|
|
12
13
|
require_relative 'calmlens/transforms'
|
|
13
14
|
require_relative 'calmlens/response'
|
|
@@ -19,6 +20,7 @@ require_relative 'calmlens/moderation_context'
|
|
|
19
20
|
require_relative 'calmlens/assets'
|
|
20
21
|
require_relative 'calmlens/folders'
|
|
21
22
|
require_relative 'calmlens/hash_lists'
|
|
23
|
+
require_relative 'calmlens/policies'
|
|
22
24
|
require_relative 'calmlens/text'
|
|
23
25
|
require_relative 'calmlens/upload_tokens'
|
|
24
26
|
require_relative 'calmlens/events'
|