calmlens 0.22.0 → 0.23.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5fd7cb8e811f68c6be7989deb980a259d4a5643bc6f4402324c64236d258661b
4
- data.tar.gz: f5e03fc720335e9c2ee2116549ddad9407a627449aed230f31eacf9a31743a3d
3
+ metadata.gz: f31d8d0e9739bebd69a0bab938d60914fb7173c8543b5521bcd5ac97e441311a
4
+ data.tar.gz: c85ad7397dd68637309b17204ff12dc7d52ec1e6415d66c42bacb7a000b9e9cf
5
5
  SHA512:
6
- metadata.gz: ea06ee7b551c7e4a4336f3fb972e0e1181a55b5a148cb89c3b281dff789efe60289db280c18ffe700f1e883f763be60b10560b6a8321773c776aa40df2c57c13
7
- data.tar.gz: b50571100307aaa792130cf4ca1d980232185613d9376a244b7857a7f052e0e43ffcbaa584fa661e869d349c3971e9d5199a07ac053844ee3ddbaa496db5aa81
6
+ metadata.gz: 7fb2274df7c0dfa834c97e786ab3b37816ec447c5d7694a293866a75882f11e4a052e684c3c1559a477a7e7419a02144a2af232291bf21f868bffa9fada5eed8
7
+ data.tar.gz: '090730bd39a5d48f41a7d7cfdbda56538166304f1af6cf6b22c9046997135f15a66384054a9e02751d168696b46c5a811b01abf825fc77f8a1f04b808da1c8d1'
@@ -310,7 +310,8 @@ module CalmLens
310
310
  end
311
311
 
312
312
  # Uploads content and waits for the moderation decision where the content type supports it.
313
- # Audio and video are always processed asynchronously. Takes the options of {#create}.
313
+ # Documents, websites, audio, video and files over 20 MiB sent in parts always finish in the
314
+ # background and return `pending`. Takes the options of {#create}.
314
315
  # @return [Hash] the asset; show it to everyone only when `classification.moderation.status` is
315
316
  # `allowed` or `limited` (reduce the reach of `limited` assets). Serve `age-restricted` assets
316
317
  # only behind your own age check, through signed URLs.
data/lib/calmlens/cdn.rb CHANGED
@@ -53,7 +53,7 @@ module CalmLens
53
53
  # @option options [String] :resource `rendered` (the displayable file: an image in the best format the
54
54
  # client accepts, a PDF, or browser-safe video or audio), `original` (the exact upload), `preview` (a
55
55
  # still image for the asset), `hls` (the HLS master playlist of audio or video, at `hls/index.m3u8`,
56
- # once `content.hls` is set), `embed` (an HTML page that shows the asset, a viewer for its type, for an
56
+ # once `content.hls.status` is `ready`), `embed` (an HTML page that shows the asset, a viewer for its type, for an
57
57
  # iframe), `placeholder` (a small blurred PNG that the CDN serves publicly for every asset whatever its
58
58
  # visibility or moderation status, without age confirmation) or `info` (the asset's display info, as
59
59
  # {#get_asset_info} reads it). Without it, the URL is the asset URL itself. Lenses and transforms
@@ -69,7 +69,7 @@ module CalmLens
69
69
  # alpha-2 country. The CDN serves an asset whose moderation status is `age-restricted` only with
70
70
  # `age_confirmed: true`, even with a signed URL or the API key. Set it only where your app has
71
71
  # already decided the viewer may see age-restricted content. Your platform confirms the viewer's
72
- # age; CalmLens does not verify this claim, and anyone can add it to a URL.
72
+ # age; viewer claims are assertions from your app, and CalmLens doesn't verify them.
73
73
  # @return [String]
74
74
  def get_asset_url(options)
75
75
  call(options, __method__) { |api, _request| asset_url(api) }
@@ -35,7 +35,7 @@ module CalmLens
35
35
  # @option options [String] :identifier
36
36
  # @option options [String] :name
37
37
  # @option options [String] :type `image`
38
- # @option options [Boolean] :active whether the CDN serves it
38
+ # @option options [Boolean] :active whether the CDN serves it; true by default
39
39
  # @option options [String, nil] :description
40
40
  # @option options [Hash, String, nil] :transform
41
41
  # @return [Hash] the lens
@@ -134,7 +134,7 @@ module CalmLens
134
134
  # them. Definitions this client already saved are not sent again, and sending the same
135
135
  # definition again returns the lens unchanged. More than 100 new definitions are sent in
136
136
  # consecutive requests, in order. {#lenses} manages lenses one by one.
137
- # @option options [Array<Hash>] :lenses `[{identifier:, name:, type:, active:, transform:, ...}]`
137
+ # @option options [Array<Hash>] :lenses `[{identifier:, name:, type:, transform:, ...}]`, each served unless `active: false`
138
138
  # @return [Array<Hash>] the saved lenses, in the given order.
139
139
  def ensure_lenses(options) = @lenses.ensure_lenses(options)
140
140
 
@@ -180,11 +180,10 @@ module CalmLens
180
180
  private
181
181
 
182
182
  # The fields that identify a lens definition, as canonical JSON: an unset description or
183
- # transform is the same definition as an explicit null.
183
+ # transform is the same definition as an explicit null, and an unset `active` as `true`.
184
184
  def lens_key(lens)
185
185
  fields = { 'identifier' => lens['identifier'], 'name' => lens['name'], 'description' => lens['description'],
186
- 'type' => lens['type'], 'transform' => lens['transform'] }
187
- fields['active'] = lens['active'] if lens.key?('active')
186
+ 'type' => lens['type'], 'transform' => lens['transform'], 'active' => lens.fetch('active', true) }
188
187
  JSON.generate(canonical(fields))
189
188
  end
190
189
 
@@ -8,7 +8,7 @@ module CalmLens
8
8
 
9
9
  # Each category's qualifiers.
10
10
  QUALIFIERS = {
11
- "sexual" => ["sexual-pornographic-media", "sexual-nudity", "sexual-activity", "sexual-explicit-language", "sexual-assault-media", "sexual-non-consensual", "sexual-intimate-image-abuse", "sexual-extreme", "sexual-strangulation", "sexual-incest", "sexual-minors", "sexual-solicitation", "sexual-paid", "sexual-adult-promotion", "sexual-revealing-clothing", "sexual-suggestive", "sexual-non-erotic-nudity", "sexual-text", "sexual-historical", "sexual-documentary", "sexual-educational", "sexual-discussion", "sexual-fictional", "sexual-support-prevention"].freeze,
11
+ "sexual" => ["sexual-pornographic-media", "sexual-nudity", "sexual-activity", "sexual-explicit-language", "sexual-assault-media", "sexual-non-consensual", "sexual-intimate-image-abuse", "sexual-extreme", "sexual-strangulation", "sexual-incest", "sexual-minors", "sexual-solicitation", "sexual-paid", "sexual-adult-promotion", "sexual-revealing-clothing", "sexual-suggestive", "sexual-non-erotic-nudity", "sexual-text", "sexual-historical", "sexual-documentary", "sexual-educational", "sexual-discussion", "sexual-fictional", "sexual-artistic", "sexual-support-prevention"].freeze,
12
12
  "violence" => ["violence-high-impact", "violence-attack", "violence-threat", "violence-preparation", "violence-encouragement", "violence-wish", "violence-coded-incitement", "violence-extremism", "violence-injury", "violence-violent-death", "violence-natural-death", "violence-graphic", "violence-decapitation-act", "violence-gore", "violence-weapon", "violence-sport", "violence-text", "violence-historical", "violence-documentary", "violence-educational", "violence-discussion", "violence-fictional", "violence-fantasy", "violence-support-prevention"].freeze,
13
13
  "self-harm" => ["self-harm-age-restricted", "self-harm-thoughts", "self-harm-preparation", "self-harm-act", "self-harm-methods", "self-harm-encouragement", "self-harm-historical", "self-harm-documentary", "self-harm-educational", "self-harm-fictional", "self-harm-support-prevention"].freeze,
14
14
  "harassment" => ["harassment-abuse", "harassment-mild", "harassment-bullying", "harassment-public-target", "harassment-threat", "harassment-sexual", "harassment-encouragement", "harassment-historical", "harassment-documentary", "harassment-educational", "harassment-fictional", "harassment-support-prevention"].freeze,
@@ -81,7 +81,7 @@ module CalmLens
81
81
  # from its pixels. The `placeholder` resource serves it as a PNG, like the CDN.
82
82
  THUMBHASH = '1QcSHQRnh493V4dIh4eXh1h4kJUI'
83
83
  # Removing or withholding content removes its rendered file, adaptive stream and preview, as the API does.
84
- NO_RENDERING = { 'rendered' => nil, 'hls' => nil, 'hlsStatus' => nil, 'preview' => nil }.freeze
84
+ NO_RENDERING = { 'rendered' => nil, 'hls' => nil, 'preview' => nil }.freeze
85
85
  # The size of the waveform the API makes as an audio asset's preview.
86
86
  WAVEFORM_SIZE = { 'width' => 1200, 'height' => 300 }.freeze
87
87
  # Audio and video formats browsers play as uploaded, so the API serves them as stored (Asset.ts).
@@ -272,6 +272,9 @@ module CalmLens
272
272
  # Like the API: a queued re-run no longer replaces a reviewed decision.
273
273
  @reruns.delete(item['id'])
274
274
  apply_outcome(item, decision, 'requested')
275
+ # Like the API: the human review queue's decision settles its review, one through the API
276
+ # leaves it waiting.
277
+ item['classification']['moderation']['pendingReview'] = nil unless by_api_key
275
278
  review = { 'id' => @create_id.call('rev'), 'decision' => decision, 'note' => note,
276
279
  'decidedBy' => { 'type' => 'api-key', 'id' => nil }, 'reviewedAt' => @now.call, 'applied' => true }
277
280
  actor = by_api_key ? API_KEY_ACTOR : REVIEWER_ACTOR
@@ -613,6 +616,7 @@ module CalmLens
613
616
  old = @state.lenses[definition['identifier']]
614
617
  return new_lens(definition) unless old
615
618
 
619
+ definition = active_by_default(definition)
616
620
  same = %w[name type active].all? { |key| old[key] == definition[key] } &&
617
621
  old['description'] == definition['description'] && canonical(old['transform']) == canonical(definition['transform'])
618
622
  return old if same
@@ -622,10 +626,13 @@ module CalmLens
622
626
 
623
627
  def new_lens(definition)
624
628
  now = @now.call
625
- lens = { 'id' => @create_id.call('lens'), 'projectId' => @project_id }.merge(definition)
629
+ lens = { 'id' => @create_id.call('lens'), 'projectId' => @project_id }.merge(active_by_default(definition))
626
630
  @state.lenses[definition['identifier']] = lens.merge('createdAt' => now, 'updatedAt' => now)
627
631
  end
628
632
 
633
+ # Like the API, a lens is served unless its definition says otherwise.
634
+ def active_by_default(definition) = definition['active'].nil? ? definition.merge('active' => true) : definition
635
+
629
636
  # POST /projects/:id/lenses: like the API, the identifier must be new to the project.
630
637
  def create_lens(json)
631
638
  identifier = json['identifier']
@@ -923,23 +930,70 @@ module CalmLens
923
930
 
924
931
  options, bytes = form_fields(body, request[:content_type])
925
932
  fields = options.merge('contentType' => query['contentType'])
926
- return create_asset(fields, queue, data: bytes) if upload == 'content'
933
+ return create_asset(apply_upload_preset(fields, bytes), queue, data: bytes) if upload == 'content'
934
+
935
+ start_multipart(apply_upload_preset(fields.merge(query.slice('isFinal', 'totalSizeInBytes')), bytes), bytes, queue)
936
+ end
937
+
938
+ # An API-key upload through a preset, as the API applies it: the preset gives the folder and
939
+ # visibility, which the upload can't change, and the processing options unless the upload sets
940
+ # its own, and refuses types and sizes it doesn't accept.
941
+ def apply_upload_preset(fields, data = nil)
942
+ return fields unless fields['uploadPresetId']
943
+
944
+ preset = @upload_presets.values.find { |item| item['id'] == fields['uploadPresetId'] }
945
+ fail!(ErrorCodes::UPLOAD_PRESET_NOT_FOUND) unless preset
946
+ fail!(ErrorCodes::UPLOAD_PRESET_DISABLED) unless preset['enabled']
947
+ check_preset_fields(preset, fields)
948
+ check_preset_limits(preset, fields, data)
949
+ fields.merge('folderId' => preset['folderId'] || fields['folderId'], 'visibility' => preset['visibility'],
950
+ 'processingOptions' => fields['processingOptions'] || deep_copy(preset['processingOptions']))
951
+ end
952
+
953
+ def check_preset_fields(preset, fields)
954
+ if preset['folderId'] && fields['folderId'] && fields['folderId'] != preset['folderId']
955
+ fail!(ErrorCodes::UPLOAD_PRESET_FIELD_NOT_ALLOWED, details: { 'field' => 'folderId' })
956
+ end
957
+ return unless fields['visibility'] && fields['visibility'] != preset['visibility']
958
+
959
+ fail!(ErrorCodes::UPLOAD_PRESET_FIELD_NOT_ALLOWED, details: { 'field' => 'visibility' })
960
+ end
961
+
962
+ def check_preset_limits(preset, fields, data)
963
+ allowed = preset['allowedContentTypes']
964
+ type = fields['contentType']
965
+ if allowed && type && allowed.none? { |pattern| preset_accepts?(pattern, fields['name'], type) }
966
+ fail!(ErrorCodes::UNSUPPORTED_UPLOAD_TYPE,
967
+ details: { 'source' => 'preset', 'contentType' => type, 'allowedContentTypes' => allowed })
968
+ end
969
+ limit = preset['maxSizeInBytes']
970
+ size = fields['totalSizeInBytes'] || data&.bytesize
971
+ fail!(ErrorCodes::UPLOAD_TOO_LARGE, details: { 'limitBytes' => limit, 'source' => 'preset' }) if limit && size && size > limit
972
+ end
973
+
974
+ # Whether an upload matches one entry of a preset's list, such as `image/*`, `text/plain` or `.pdf`.
975
+ def preset_accepts?(pattern, name, type)
976
+ pattern = pattern.strip.downcase
977
+ return true if ['*', '*/*'].include?(pattern)
978
+ return type.downcase.start_with?(pattern.delete_suffix('*')) if pattern.end_with?('/*')
979
+ return name.to_s.downcase.end_with?(pattern) if pattern.start_with?('.')
927
980
 
928
- start_multipart(fields.merge(query.slice('isFinal', 'totalSizeInBytes')), bytes, queue)
981
+ type.downcase == pattern
929
982
  end
930
983
 
931
984
  # A JSON create: text as `content` or a first `part` (`{ plainText, type }`), or a URL.
932
985
  def create_from_json(json, queue)
933
986
  key = %w[content part].find { |name| json.key?(name) }
934
- return create_asset(json, queue, url: json['url']) unless key
987
+ return create_asset(apply_upload_preset(json), queue, url: json['url']) unless key
935
988
 
936
989
  text = json[key]
937
990
  invalid!('validation/invalid-type', key) unless text.is_a?(Hash) && text['plainText'].is_a?(String) && text['type'].is_a?(String)
938
991
  fields = json.except('content', 'part', 'sizeInBytes', 'totalSizeInBytes').merge('contentType' => text['type'])
939
992
  data = text['plainText'].encode(Encoding::UTF_8).b
940
- return create_asset(fields, queue, data: data) if key == 'content'
993
+ return create_asset(apply_upload_preset(fields, data), queue, data: data) if key == 'content'
941
994
 
942
- start_multipart(fields.merge('isFinal' => text['isFinal'] == true, 'totalSizeInBytes' => json['totalSizeInBytes']), data, queue)
995
+ part = fields.merge('isFinal' => text['isFinal'] == true, 'totalSizeInBytes' => json['totalSizeInBytes'])
996
+ start_multipart(apply_upload_preset(part, data), data, queue)
943
997
  end
944
998
 
945
999
  # The asset fields in an upload form's options part, and the file's bytes. Like the API, a raw
@@ -1023,7 +1077,7 @@ module CalmLens
1023
1077
  # faces in an image, so a decided image reports that the features its upload ran found nothing:
1024
1078
  # OCR and person and face detection when turned on, link detection unless turned off.
1025
1079
  def decided(asset, decision)
1026
- apply_outcome(asset, decision.status, 'policy', decision.report)
1080
+ apply_outcome(asset, decision.status, 'policy', decision.report(@now.call))
1027
1081
  classification = asset['classification']
1028
1082
  return asset unless classification['status'] == 'completed' && asset['type'] == 'image'
1029
1083
 
@@ -1136,7 +1190,7 @@ module CalmLens
1136
1190
  # play the upload), audio with a waveform preview and video with no poster; and every preview has
1137
1191
  # its ThumbHash. Text, spreadsheets and other files have neither. The mock decodes nothing, so
1138
1192
  # sizes are unknown, audio has no peaks, and audio and video never get an adaptive stream.
1139
- def rendering(asset, processed) = { **rendered_file(asset, processed), 'hls' => nil, 'hlsStatus' => nil }
1193
+ def rendering(asset, processed) = { **rendered_file(asset, processed), 'hls' => nil }
1140
1194
 
1141
1195
  # The rendered file and preview of {#rendering}.
1142
1196
  def rendered_file(asset, processed)
@@ -1746,9 +1800,9 @@ module CalmLens
1746
1800
  grant, item = resolve_asset(raw_segments[2])
1747
1801
  return grant if grant.is_a?(Response)
1748
1802
  return asset_not_found unless item && RESOURCES.include?(resource)
1749
- if resource == 'original' && grant && !grant[:original]
1750
- return error(ErrorCodes::SIGNED_URL_ORIGINAL_NOT_GRANTED, "This signed asset URL doesn't grant the original file", status: 403)
1751
- end
1803
+
1804
+ refused = refuse_viewer(item, resource, grant, headers)
1805
+ return refused if refused
1752
1806
 
1753
1807
  params = URI.decode_www_form(query.to_s).to_h
1754
1808
  # A grant authorises a private asset without the API key.
@@ -1760,6 +1814,18 @@ module CalmLens
1760
1814
  deliver(method, item, resource, params, selected ? options || {} : nil, authorised)
1761
1815
  end
1762
1816
 
1817
+ # What a grant doesn't cover, or, like the CDN, a private asset for anyone without a grant or the
1818
+ # project's API key: it answers as an unknown asset does, whatever its state, except for its
1819
+ # public placeholder and its info, which has its own rule. An error response, or nil.
1820
+ def refuse_viewer(item, resource, grant, headers)
1821
+ if resource == 'original' && grant && !grant[:original]
1822
+ return error(ErrorCodes::SIGNED_URL_ORIGINAL_NOT_GRANTED, "This signed asset URL doesn't grant the original file", status: 403)
1823
+ end
1824
+ return if grant || item['visibility'] == 'public' || header(headers, 'X-Api-Key') == @api_key
1825
+
1826
+ asset_not_found unless %w[placeholder info].include?(resource)
1827
+ end
1828
+
1763
1829
  # Whether the path after the resource selects a lens or transform, and its options; nil when the
1764
1830
  # CDN refuses it. Like the CDN, the adaptive stream's files sit beneath `/hls/` (its master
1765
1831
  # playlist is `/hls/index.m3u8`). Otherwise the selector after the resource is read
@@ -5,11 +5,13 @@ require_relative 'classification'
5
5
  module CalmLens
6
6
  # A moderation decision for {MockServerClient}'s `moderate:` option or
7
7
  # {MockServerClient#complete_processing}, started with {.of}: flag the categories it found, with
8
- # their scores and qualifiers, and mark the categories it couldn't assess. Each call checks its
9
- # names and scores and raises `sdk/invalid-argument` for one CalmLens doesn't have.
8
+ # their scores and qualifiers, mark the categories it couldn't assess, and leave it waiting for
9
+ # human review. Each call checks its names and scores and raises `sdk/invalid-argument` for one
10
+ # CalmLens doesn't have.
10
11
  #
11
12
  # @example
12
13
  # CalmLens::MockDecision.of('blocked').flag('violence', 0.9, 'violence-threat' => 0.9).not_evaluated('profanity')
14
+ # CalmLens::MockDecision.of('limited').flag('violence', 0.5).pending_review('uncertain', ['violence'])
13
15
  class MockDecision
14
16
  # Where the mock's moderation leaves an upload: `pending` (still processing), `failed`
15
17
  # (processing failed, retryable), or a decision with that moderation status (`purged` also
@@ -19,6 +21,9 @@ module CalmLens
19
21
  STATUS_ACTIONS = { 'allowed' => 'allow', 'limited' => 'limit', 'age-restricted' => 'age-restrict', 'blocked' => 'block',
20
22
  'purged' => 'purge' }.freeze
21
23
  private_constant :STATUS_ACTIONS
24
+ # Why a decision waits for human review.
25
+ PENDING_REVIEW_REASONS = %w[sampled uncertain].freeze
26
+ private_constant :PENDING_REVIEW_REASONS
22
27
 
23
28
  # @return [String]
24
29
  attr_reader :status
@@ -81,18 +86,40 @@ module CalmLens
81
86
  self
82
87
  end
83
88
 
89
+ # Leaves the decision waiting for human review, as `classification.moderation.pendingReview`
90
+ # reports: `sampled`, or `uncertain` with the `uncertain_categories` that made it so.
91
+ # {MockServerClient#decide} clears it; {Assets#decide} doesn't, as with the API.
92
+ # @param reason [String] `sampled` or `uncertain`
93
+ # @param uncertain_categories [Array<String>]
94
+ # @return [self]
95
+ # @raise [InvalidArgumentError] for a `pending` or `failed` decision, another reason, a `sampled`
96
+ # reason with uncertain categories, or an unknown category
97
+ def pending_review(reason, uncertain_categories = [])
98
+ raise Internal.invalid("a #{@status} decision can't wait for review") if undecided?
99
+
100
+ reason = reason.to_s
101
+ raise Internal.invalid("reason must be one of #{PENDING_REVIEW_REASONS.join(', ')}") unless PENDING_REVIEW_REASONS.include?(reason)
102
+ raise Internal.invalid('uncertain_categories must be an Array') unless uncertain_categories.is_a?(Array)
103
+ raise Internal.invalid('a sampled review has no uncertain categories') if reason == 'sampled' && !uncertain_categories.empty?
104
+
105
+ @pending_review = { 'reason' => reason, 'uncertainCategories' => uncertain_categories.map { |category| known_category(category) } }
106
+ self
107
+ end
108
+
84
109
  # The report a decided asset gets, as the API sends it: every category (unflagged ones
85
110
  # evaluated with score 0), the policy's actions, the automated status and `flaggedCategories`.
111
+ # @param requested_at [Integer] when a pending review joined the queue, in epoch milliseconds
86
112
  # @return [Hash{String => Object}, nil] `categories`, `score` and `policy`; nil for `pending` and `failed`
87
- def report
113
+ def report(requested_at = 0)
88
114
  return if undecided?
89
115
 
90
116
  evaluated = (MockClassification::CATEGORIES - @not_evaluated).map { |category| [category, @flags.dig(category, :score) || 0] }
91
117
  { 'raw' => { 'categories' => MockClassification::CATEGORIES.to_h { |category| [category, category_report(category)] },
92
118
  'hashMatches' => [] },
93
- 'categories' => evaluated.to_h { |category, value| [category, { 'score' => value, 'action' => action(value) }] },
119
+ 'categories' => evaluated.to_h { |category, value| [category, policy_result(value)] },
94
120
  # The mock keeps no policies, so no policy is named.
95
121
  'explanationComplete' => true, 'policy' => nil, 'source' => 'policy', 'rule' => nil, 'automatedStatus' => @status,
122
+ 'pendingReview' => @pending_review && { 'requestedAt' => requested_at, **@pending_review },
96
123
  'regions' => [], 'linkSafety' => nil, 'flaggedCategories' => evaluated.reject { |_, value| action(value) == 'allow' }.map(&:first) }
97
124
  end
98
125
 
@@ -102,9 +129,15 @@ module CalmLens
102
129
 
103
130
  def undecided? = %w[pending failed].include?(@status)
104
131
 
105
- def check_category(category)
132
+ def known_category(category)
106
133
  category = category.to_s
107
134
  raise Internal.invalid("#{category} is not a CalmLens category") unless MockClassification::CATEGORIES.include?(category)
135
+
136
+ category
137
+ end
138
+
139
+ def check_category(category)
140
+ category = known_category(category)
108
141
  raise Internal.invalid("a #{@status} decision has no findings") if undecided?
109
142
  raise Internal.invalid("#{category} is already in this decision") if @flags.key?(category) || @not_evaluated.include?(category)
110
143
 
@@ -115,6 +148,13 @@ module CalmLens
115
148
 
116
149
  def action(score) = @status != 'allowed' && score >= 0.5 ? STATUS_ACTIONS[@status] : 'allow'
117
150
 
151
+ # A category's policy result. The mock keeps no policies: the category's setting is the
152
+ # decision's own action.
153
+ def policy_result(score)
154
+ { 'score' => score, 'action' => action(score), 'source' => { 'kind' => score >= 0.5 ? 'default' : 'below-threshold' },
155
+ 'settings' => { 'action' => STATUS_ACTIONS[@status], 'allowances' => [], 'restrictions' => [] } }
156
+ end
157
+
118
158
  # The API's likelihood for a score (ClassificationCategories.ts, scoreToLikelihood).
119
159
  def likelihood(score)
120
160
  if score >= 0.8 then 'very-likely'
@@ -56,7 +56,7 @@ module CalmLens
56
56
  person = { 'box' => [2500, 1500, 5000, 8500] }
57
57
  face = ->(age = nil) { { 'box' => [3500, 2000, 3000, 3500], **(age ? { 'age' => age } : {}) } }
58
58
  # Ready-made scenarios (deeply frozen): a clean input, a limited one, adult images and text,
59
- # regional delivery, content restricted in Germany, mixed instances in one category, and test
59
+ # regional delivery, delivery blocked in Germany, mixed instances in one category, and test
60
60
  # images with one person and face, nobody, an adult's face, a minor's face and a group of three.
61
61
  PRESETS = deep_freeze.call(
62
62
  clean: { 'instances' => [] },
@@ -66,7 +66,7 @@ module CalmLens
66
66
  regional_delivery: { 'instances' => [], 'regionalActions' => [{ 'region' => 'DE', 'action' => 'block' },
67
67
  { 'region' => 'GB', 'action' => 'age-restrict' },
68
68
  { 'region' => 'FR', 'action' => 'limit' }] },
69
- germany: one.call('hate', 'hate-symbol', 'hate-nazi-symbols-public-display'),
69
+ germany: { 'instances' => [], 'regionalActions' => [{ 'region' => 'DE', 'action' => 'block' }] },
70
70
  mixed: { 'instances' => [{ 'category' => 'harassment', 'qualifiers' => %w[harassment-abuse harassment-documentary] },
71
71
  { 'category' => 'harassment', 'qualifiers' => ['harassment-threat'] }] },
72
72
  one_face: { 'instances' => [], 'detections' => { 'people' => [person], 'faces' => [face.call] } },
@@ -97,7 +97,8 @@ module CalmLens
97
97
  preset = case signal
98
98
  when 'harassment.insult' then PRESETS[:limited]
99
99
  when 'sexual.adult' then PRESETS[kind == 'image' ? :adult_image : :adult_text]
100
- when 'hate.regional-symbol' then PRESETS[:germany]
100
+ when 'hate.regional-symbol'
101
+ { 'instances' => [{ 'category' => 'hate', 'qualifiers' => %w[hate-symbol hate-nazi-symbols-public-display] }] }
101
102
  when 'sexual.involving-minors' then { 'instances' => [{ 'category' => 'sexual', 'qualifiers' => ['sexual-minors'] }] }
102
103
  when 'violence.graphic'
103
104
  { 'instances' => [{ 'category' => 'violence', 'qualifiers' => %w[violence-injury violence-graphic] }] }
@@ -27,8 +27,10 @@ module CalmLens
27
27
  # grant (the default key has no grants), or the URL is signed: {CDN#sign_asset_url} returns
28
28
  # `{cdn_base_url}/assets/{project_id}/sig_…`, a readable grant that only the mock's CDN accepts,
29
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
31
- # {Assets#wait_for_decision} takes no real time: a pending asset times out at once.
30
+ # without the API key, as a browser would; without the key or a grant, a private asset answers
31
+ # as an unknown one does, `asset/not-found` (404) whatever its state, and only its placeholder is
32
+ # public. {#decide} applies a reviewer's decision, and {Assets#wait_for_decision} takes no real
33
+ # time: a pending asset times out at once.
32
34
  # Filters join comparisons with `;` (and), which binds tighter than `,` (or), and group them with
33
35
  # `( … )`; list cursors resume after the last item returned. {Assets#search} is the API's keyword
34
36
  # search: every query word (case-insensitive, up to 8) must appear in a searchable field, and
@@ -78,7 +80,7 @@ module CalmLens
78
80
  REQUEST_FAILURES = %w[transport-error timeout].freeze
79
81
  # The methods {#fail_next_request} can target, by resource. Iterators are their list calls.
80
82
  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],
83
+ retry explain decide wait_for_decision remove_content delete batch],
82
84
  folders: %i[list retrieve create update delete move],
83
85
  hash_lists: %i[list retrieve create update delete list_entries add_entry remove_entry],
84
86
  policies: %i[list retrieve retrieve_assigned create update reset upgrade set_automatic_updates delete
@@ -200,8 +202,9 @@ module CalmLens
200
202
 
201
203
  # Applies a reviewer's decision to a stored asset, as the portal or API would: the moderation
202
204
  # 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.
205
+ # `decidedBy` the mock API key (`{ 'type' => 'api-key', 'id' => nil }`) and `applied: true`, an
206
+ # `asset.<decision>` event whose `actor` is a `user` joins the events feed, and a waiting
207
+ # `pendingReview` is settled ({Assets#decide} leaves it waiting, as with the API).
205
208
  # @param asset_id [String]
206
209
  # @param decision [String] `allowed`, `limited`, `age-restricted`, `blocked` or `purged`
207
210
  # @return [Hash] the asset
@@ -271,7 +274,8 @@ module CalmLens
271
274
  # Fetches a URL of the mock's CDN as a browser would, with a GET and without the API key: a signed
272
275
  # asset URL from {CDN#sign_asset_url} (with a resource appended), or a public one from
273
276
  # {CDN#get_asset_url}. The request is recorded like any other. Error statuses are returned, not
274
- # raised.
277
+ # raised. A private asset without a grant answers `asset/not-found` (404) whatever its state,
278
+ # except for its public placeholder.
275
279
  # @param url [String]
276
280
  # @return [Response] with its body read
277
281
  def fetch_cdn(url)
@@ -46,20 +46,6 @@ module CalmLens
46
46
  "blur": {
47
47
  "type": "number"
48
48
  },
49
- "redact": {
50
- "type": "object",
51
- "properties": {
52
- "text": {
53
- "type": "boolean"
54
- },
55
- "personalInformation": {
56
- "type": "boolean"
57
- },
58
- "faces": {
59
- "type": "boolean"
60
- }
61
- }
62
- },
63
49
  "border": {
64
50
  "type": "object",
65
51
  "properties": {
@@ -19,7 +19,10 @@ module CalmLens
19
19
  # @!attribute open_timeout [Numeric] seconds allowed to open a connection.
20
20
  # @!attribute timeout [Numeric] seconds allowed for each read or write; not a whole-transfer deadline.
21
21
  Request = Struct.new(:method, :url, :headers, :body, :open_timeout, :timeout, keyword_init: true) do
22
+ # Struct's own `to_s` and `pretty_print` would show the headers, which hold the API key.
22
23
  def inspect = "#<#{self.class.name} #{method} #{url}>"
24
+ def to_s = inspect
25
+ def pretty_print(printer) = printer.text(inspect)
23
26
  end
24
27
 
25
28
  # Exceptions that mean the connection failed before a complete response arrived.
@@ -2,5 +2,5 @@
2
2
 
3
3
  module CalmLens
4
4
  # The gem version. The gemspec and the User-Agent header read it from here.
5
- VERSION = '0.22.0'
5
+ VERSION = '0.23.0'
6
6
  end
data/sig/calmlens.rbs CHANGED
@@ -470,8 +470,11 @@ module CalmLens
470
470
  # Flags `category` with a score from 0 to 1 and optional qualifier scores.
471
471
  def flag: (String | Symbol category, Numeric score, ?Hash[String | Symbol, Numeric] qualifiers) -> self
472
472
  def not_evaluated: (String | Symbol category) -> self
473
+ # Leaves the decision waiting for human review: `sampled`, or `uncertain` with the categories that made it so.
474
+ def pending_review: ("sampled" | "uncertain" | :sampled | :uncertain reason, ?Array[String | Symbol] uncertain_categories) -> self
473
475
  # The `categories`, `score` and `policy` a decided asset gets; nil for `pending` and `failed`.
474
- def report: () -> Hash[String, json_value]?
476
+ # `requested_at` is when a pending review joined the queue, in epoch milliseconds.
477
+ def report: (?Integer requested_at) -> Hash[String, json_value]?
475
478
  end
476
479
 
477
480
  # What MockServerClient's `moderate:` callback receives.
data/sig/qualifiers.rbs CHANGED
@@ -57,6 +57,7 @@ module CalmLens
57
57
  ?"sexual-educational" => qualifier_result,
58
58
  ?"sexual-discussion" => qualifier_result,
59
59
  ?"sexual-fictional" => qualifier_result,
60
+ ?"sexual-artistic" => qualifier_result,
60
61
  ?"sexual-support-prevention" => qualifier_result,
61
62
  ?"violence-attack" => qualifier_result,
62
63
  ?"violence-threat" => qualifier_result,
data/sig/types.rbs CHANGED
@@ -102,8 +102,11 @@ module CalmLens
102
102
  # Preview. The adaptive stream of audio or video: HLS with fragmented MP4 (CMAF) segments, served
103
103
  # at the CDN's `hls` resource (`hls/index.m3u8`) and played by its `embed` resource. Renditions list
104
104
  # video from the largest down, then audio.
105
- type asset_hls = { "renditions" => Array[asset_hls_rendition] }
106
- type asset_hls_status = { "status" => String, "reason" => String? }
105
+ type asset_hls = {
106
+ "status" => String,
107
+ "reason" => String?,
108
+ "renditions" => Array[asset_hls_rendition]
109
+ }
107
110
  type asset_rendered_kind = "image" | "pdf" | "video" | "audio"
108
111
  # The asset's one displayable file, as the CDN's `rendered` resource serves it: an image, a PDF, or
109
112
  # browser-safe video or audio. `peaks` is an audio waveform's normalised peaks.
@@ -262,7 +265,6 @@ module CalmLens
262
265
  # Preview. The adaptive stream once streaming has prepared one for audio or video; `nil` for
263
266
  # other assets, until then, once the content is removed and while the asset is held.
264
267
  "hls" => asset_hls?,
265
- "hlsStatus" => asset_hls_status?,
266
268
  "preview" => asset_preview?
267
269
  }
268
270
 
@@ -434,8 +436,7 @@ module CalmLens
434
436
  }
435
437
  # Where OCR found text in an image, and whether it is personal information.
436
438
  type text_region = { "box" => bounding_box, "personalInformation" => bool }
437
- # `status: "evaluated"` carries `textRegions`, where OCR found text (redaction transforms blur
438
- # these regions); `"not-evaluated"` carries `reason`. The text itself is `content.extractedText`.
439
+ # `status: "evaluated"` carries `textRegions`, where OCR found text; `"not-evaluated"` carries `reason`. The text itself is `content.extractedText`.
439
440
  type ocr_result = {
440
441
  "status" => "evaluated" | "not-evaluated",
441
442
  ?"textRegions" => Array[text_region],
@@ -543,11 +544,6 @@ module CalmLens
543
544
  ?anim: bool,
544
545
  ?background: String,
545
546
  ?blur: number,
546
- ?redact: {
547
- ?text: bool,
548
- ?personalInformation: bool,
549
- ?faces: bool
550
- },
551
547
  ?border: {
552
548
  ?color: String,
553
549
  ?width: number
@@ -590,11 +586,6 @@ module CalmLens
590
586
  ?"anim" => bool,
591
587
  ?"background" => String,
592
588
  ?"blur" => number,
593
- ?"redact" => {
594
- ?"text" => bool,
595
- ?"personalInformation" => bool,
596
- ?"faces" => bool
597
- },
598
589
  ?"border" => {
599
590
  ?"color" => String,
600
591
  ?"width" => number
@@ -635,12 +626,12 @@ module CalmLens
635
626
  type transform_input = String | image_transform_options_input
636
627
 
637
628
  type lens_type = "image" | "video" | "audio" | "document" | "website"
638
- # ENSURE_LENS_BODY_SCHEMA: a lens definition, which the CDN serves once it is saved.
629
+ # ENSURE_LENS_BODY_SCHEMA: a lens definition, which the CDN serves once it is saved unless `active` is false.
639
630
  type lens_definition = {
640
631
  identifier: String,
641
632
  name: String,
642
633
  type: lens_type,
643
- active: bool,
634
+ ?active: bool,
644
635
  ?description: String?,
645
636
  ?transform: image_transform_options_input?
646
637
  }
@@ -1168,7 +1159,7 @@ module CalmLens
1168
1159
  identifier: String,
1169
1160
  name: String,
1170
1161
  type: lens_type,
1171
- active: bool,
1162
+ ?active: bool,
1172
1163
  ?description: String?,
1173
1164
  ?transform: image_transform_options_input?,
1174
1165
  ?request_options: request_options
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: calmlens
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.22.0
4
+ version: 0.23.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - CalmLens
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-11-08 00:00:00.000000000 Z
11
+ date: 2026-11-09 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: 'Server-side client for the CalmLens API: moderation, uploads, events,
14
14
  webhooks and CDN delivery.'