Skip to content

Commit 58da460

Browse files
committed
Admin action to generate report for moderation transparency metrics.
1 parent 6449aa3 commit 58da460

File tree

4 files changed

+642
-14
lines changed

4 files changed

+642
-14
lines changed

app/lib/admin/actions/actions.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import 'moderation_case_info.dart';
1717
import 'moderation_case_list.dart';
1818
import 'moderation_case_resolve.dart';
1919
import 'moderation_case_update.dart';
20+
import 'moderation_transparency_metrics.dart';
2021
import 'package_info.dart';
2122
import 'package_version_info.dart';
2223
import 'package_version_retraction.dart';
@@ -97,6 +98,7 @@ final class AdminAction {
9798
moderationCaseList,
9899
moderationCaseResolve,
99100
moderationCaseUpdate,
101+
moderationTransparencyMetrics,
100102
packageInfo,
101103
packageVersionInfo,
102104
packageVersionRetraction,
Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
import 'package:pub_dev/account/models.dart';
6+
import 'package:pub_dev/admin/models.dart';
7+
import 'package:pub_dev/shared/datastore.dart';
8+
9+
import 'actions.dart';
10+
11+
final moderationTransparencyMetrics = AdminAction(
12+
name: 'moderation-transparency-metrics',
13+
summary: 'Collects and provides transparency metrics.',
14+
description: '''
15+
Scans ModerationCase and User entities and collects statistics
16+
required for the transparency metrics report.
17+
''',
18+
options: {
19+
'start':
20+
'The inclusive start date of the reported period in the format of `YYYY-MM-DD`.',
21+
'end':
22+
'The inclusive end date of the reported period in the format of `YYYY-MM-DD`.',
23+
},
24+
invoke: (options) async {
25+
final dateRegExp = RegExp(r'^\d{4}\-\d{2}\-\d{2}$');
26+
27+
DateTime parseDate(String key) {
28+
final param = options[key] ?? '';
29+
InvalidInputException.check(
30+
dateRegExp.matchAsPrefix(param) != null,
31+
'`$key` must be a valid date in YYYY-MM-DD format',
32+
);
33+
final parsed = DateTime.tryParse(param);
34+
InvalidInputException.check(
35+
parsed != null,
36+
'`$key` must be a valid date in YYYY-MM-DD format',
37+
);
38+
return parsed!;
39+
}
40+
41+
final start = parseDate('start');
42+
final end = parseDate('end');
43+
44+
// The number of cases where a moderation action has been done.
45+
var totalModerationCount = 0;
46+
// The grouped counts of moderation actions by violations.
47+
final violations = <String, int>{};
48+
// The grouped counts of moderation actions by detection sources.
49+
final sources = <String, int>{};
50+
// The grouped counts of moderation actions by applied restrictions.
51+
final restrictions = <String, int>{};
52+
53+
// The number of appeals.
54+
var totalAppealCount = 0;
55+
// The number of appeals where the person responding is a known content owner.
56+
var contentOwnerAppealCount = 0;
57+
// The grouped counts of appeals by outcomes.
58+
final appealOutcomes = <String, int>{};
59+
// The list of time-to-action days - required for reporting the median.
60+
final appealTimeToActionDays = <int>[];
61+
62+
final mcQuery = dbService.query<ModerationCase>()
63+
// timestamp start on 00:00:00 on the day
64+
..filter('resolved >=', start)
65+
// adding an extra day to make sure the end day is fully included
66+
..filter('resolved <', end.add(Duration(days: 1)));
67+
await for (final mc in mcQuery.run()) {
68+
// sanity check
69+
if (mc.resolved == null) {
70+
continue;
71+
}
72+
73+
// Report group #1: case has moderated action. Whether the case was
74+
// a notification or appeal won't make a difference for this group.
75+
if (mc.status == ModerationStatus.moderationApplied ||
76+
mc.status == ModerationStatus.noActionReverted) {
77+
totalModerationCount++;
78+
violations.increment(mc.violation ?? '');
79+
sources.increment(mc.source);
80+
81+
final hasUserRestriction = mc
82+
.getActionLog()
83+
.entries
84+
.any((e) => ModerationSubject.tryParse(e.subject)!.isUser);
85+
if (hasUserRestriction) {
86+
restrictions.increment('provision');
87+
} else {
88+
restrictions.increment('visibility');
89+
}
90+
}
91+
92+
// Report group #2: appeals.
93+
if (mc.appealedCaseId != null) {
94+
totalAppealCount++;
95+
if (mc.isSubjectOwner) {
96+
contentOwnerAppealCount++;
97+
}
98+
99+
switch (mc.status) {
100+
case ModerationStatus.noActionUpheld:
101+
case ModerationStatus.moderationUpheld:
102+
appealOutcomes.increment('upheld');
103+
break;
104+
case ModerationStatus.noActionReverted:
105+
case ModerationStatus.moderationReverted:
106+
appealOutcomes.increment('reverted');
107+
break;
108+
default:
109+
appealOutcomes.increment('omitted');
110+
break;
111+
}
112+
113+
final timeToActionDays = mc.resolved!.difference(mc.opened).inDays + 1;
114+
appealTimeToActionDays.add(timeToActionDays);
115+
}
116+
}
117+
118+
appealTimeToActionDays.sort();
119+
final appealMedianTimeToActionDays = appealTimeToActionDays.isEmpty
120+
? 0
121+
: appealTimeToActionDays[appealTimeToActionDays.length ~/ 2];
122+
123+
final userQuery = dbService.query<User>()
124+
// timestamp start on 00:00:00 on the day
125+
..filter('moderatedAt >=', start)
126+
// adding an extra day to make sure the end day is fully included
127+
..filter('moderatedAt <', end.add(Duration(days: 1)));
128+
final reasonCounts = <String, int>{};
129+
await for (final user in userQuery.run()) {
130+
// sanity check
131+
if (user.moderatedAt == null) {
132+
continue;
133+
}
134+
135+
// Report group #3: user restrictions.
136+
reasonCounts.increment(user.moderatedReason ?? '');
137+
}
138+
139+
final text = toCsV([
140+
// ---------------------------------------
141+
['Restrictive actions', ''],
142+
['Total number of actions taken', totalModerationCount],
143+
[
144+
'Number of actions taken, by type of illegal content or violation of terms and conditions',
145+
'',
146+
],
147+
[
148+
'VIOLATION_CATEGORY_ANIMAL_WELFARE',
149+
violations[ModerationViolation.animalWelfare] ?? 0,
150+
],
151+
[
152+
'VIOLATION_CATEGORY_DATA_PROTECTION_AND_PRIVACY_VIOLATIONS',
153+
violations[ModerationViolation.dataProtectionAndPrivacyViolations] ?? 0,
154+
],
155+
[
156+
'VIOLATION_CATEGORY_ILLEGAL_OR_HARMFUL_SPEECH',
157+
violations[ModerationViolation.illegalAndHatefulSpeech] ?? 0,
158+
],
159+
[
160+
'VIOLATION_CATEGORY_INTELLECTUAL_PROPERTY_INFRINGEMENTS',
161+
violations[ModerationViolation.intellectualPropertyInfringements] ?? 0,
162+
],
163+
[
164+
'VIOLATION_CATEGORY_NEGATIVE_EFFECTS_ON_CIVIC_DISCOURSE_OR_ELECTIONS',
165+
violations[ModerationViolation
166+
.negativeEffectsOnCivicDiscourseOrElections] ??
167+
0,
168+
],
169+
[
170+
'VIOLATION_CATEGORY_NON_CONSENSUAL_BEHAVIOUR',
171+
violations[ModerationViolation.nonConsensualBehavior] ?? 0,
172+
],
173+
[
174+
'VIOLATION_CATEGORY_PORNOGRAPHY_OR_SEXUALIZED_CONTENT',
175+
violations[ModerationViolation.pornographyOrSexualizedContent] ?? 0,
176+
],
177+
[
178+
'VIOLATION_CATEGORY_PROTECTION_OF_MINORS',
179+
violations[ModerationViolation.protectionOfMinors] ?? 0,
180+
],
181+
[
182+
'VIOLATION_CATEGORY_RISK_FOR_PUBLIC_SECURITY',
183+
violations[ModerationViolation.riskForPublicSecurity] ?? 0,
184+
],
185+
[
186+
'VIOLATION_CATEGORY_SCAMS_AND_FRAUD',
187+
violations[ModerationViolation.scamsAndFraud] ?? 0,
188+
],
189+
[
190+
'VIOLATION_CATEGORY_SELF_HARM',
191+
violations[ModerationViolation.selfHarm] ?? 0,
192+
],
193+
[
194+
'VIOLATION_CATEGORY_SCOPE_OF_PLATFORM_SERVICE',
195+
violations[ModerationViolation.scopeOfPlatformService] ?? 0,
196+
],
197+
[
198+
'VIOLATION_CATEGORY_UNSAFE_AND_ILLEGAL_PRODUCTS',
199+
violations[ModerationViolation.unsafeAndIllegalProducts] ?? 0,
200+
],
201+
[
202+
'VIOLATION_CATEGORY_VIOLENCE',
203+
violations[ModerationViolation.violence] ?? 0,
204+
],
205+
['Number of actions taken, by detection method', ''],
206+
[
207+
'Automated detection',
208+
sources[ModerationSource.automatedDetection] ?? 0
209+
],
210+
[
211+
'Non-automated detection',
212+
sources.entries
213+
.where((e) => e.key != ModerationSource.automatedDetection)
214+
.map((e) => e.value)
215+
.fold<int>(0, (a, b) => a + b)
216+
],
217+
['Number of actions taken, by type of restriction applied', ''],
218+
[
219+
'Restrictions of Visibility',
220+
restrictions['visibility'] ?? 0,
221+
],
222+
[
223+
'Restrictions of Monetisation',
224+
restrictions['monetisation'] ?? 0,
225+
],
226+
[
227+
'Restrictions of Provision of the Service',
228+
restrictions['provision'] ?? 0,
229+
],
230+
[
231+
'Restrictions of an Account',
232+
restrictions['account'] ?? 0,
233+
],
234+
235+
// ---------------------------------------
236+
['Complaints received through internal complaint handling systems', ''],
237+
['Total number of complaints received', totalAppealCount],
238+
['Number of complaints received, by reason for complaint', ''],
239+
['CONTENT_ACCOUNT_OWNER_APPEAL', contentOwnerAppealCount],
240+
['REPORTER_APPEAL', totalAppealCount - contentOwnerAppealCount],
241+
['Number of complaints received, by outcome', ''],
242+
[
243+
'Initial decision upheld',
244+
appealOutcomes['upheld'] ?? 0,
245+
],
246+
[
247+
'Initial decision reversed',
248+
appealOutcomes['reverted'] ?? 0,
249+
],
250+
[
251+
'Decision omitted',
252+
appealOutcomes['omitted'] ?? 0,
253+
],
254+
[
255+
'Median time to action a complaint (days)',
256+
appealMedianTimeToActionDays,
257+
],
258+
259+
// ---------------------------------------
260+
['Suspensions imposed to protect against misuse', ''],
261+
[
262+
'Number of suspensions for manifestly illegal content imposed pursuant to Article 23',
263+
reasonCounts[UserModeratedReason.illegalContent] ?? 0,
264+
],
265+
[
266+
'Number of suspensions for manifestly unfounded notices imposed pursuant to Article 23',
267+
reasonCounts[UserModeratedReason.unfoundedNotifications] ?? 0,
268+
],
269+
[
270+
'Number of suspensions for manifestly unfounded complaints imposed pursuant to Article 23',
271+
reasonCounts[UserModeratedReason.unfoundedAppeals] ?? 0,
272+
],
273+
]);
274+
275+
return {
276+
'text': text,
277+
'moderations': {
278+
'total': totalModerationCount,
279+
'violations': violations,
280+
'sources': sources,
281+
'restrictions': restrictions,
282+
},
283+
'appeals': {
284+
'total': totalAppealCount,
285+
'contentOwner': contentOwnerAppealCount,
286+
'outcomes': appealOutcomes,
287+
'medianTimeToActionDays': appealMedianTimeToActionDays,
288+
},
289+
'users': {
290+
'suspensions': reasonCounts,
291+
}
292+
};
293+
},
294+
);
295+
296+
/// Loose implementation of RFC 4180 writing tabular data into Comma Separated Values.
297+
/// The current implementation supports only String and int values.
298+
String toCsV(List<List<Object>> data) {
299+
final sb = StringBuffer();
300+
for (final row in data) {
301+
for (var i = 0; i < row.length; i++) {
302+
if (i > 0) {
303+
sb.write(',');
304+
}
305+
final value = row[i];
306+
if (value is int) {
307+
sb.write(value);
308+
} else if (value is String) {
309+
final mustEscape = value.contains(',') ||
310+
value.contains('"') ||
311+
value.contains('\r') ||
312+
value.contains('\n');
313+
sb.write(mustEscape ? '"${value.replaceAll('"', '""')}"' : value);
314+
} else {
315+
throw UnimplementedError(
316+
'Unhandled CSV type: ${value.runtimeType}/$value');
317+
}
318+
}
319+
sb.write('\r\n');
320+
}
321+
return sb.toString();
322+
}
323+
324+
extension on Map<String, int> {
325+
void increment(String key) {
326+
this[key] = (this[key] ?? 0) + 1;
327+
}
328+
}

app/lib/admin/models.dart

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -268,22 +268,40 @@ abstract class ModerationGrounds {
268268

269269
abstract class ModerationViolation {
270270
static const none = 'none';
271+
static const animalWelfare = 'animal_welfare';
272+
static const dataProtectionAndPrivacyViolations =
273+
'data_protection_and_privacy_violations';
274+
static const illegalAndHatefulSpeech = 'illegal_or_harmful_speech';
275+
static const intellectualPropertyInfringements =
276+
'intellectual_property_infringements';
277+
static const negativeEffectsOnCivicDiscourseOrElections =
278+
'negative_effects_on_civic_discourse_or_elections';
279+
static const nonConsensualBehavior = 'non_consensual_behaviour';
280+
static const pornographyOrSexualizedContent =
281+
'pornography_or_sexualized_content';
282+
static const protectionOfMinors = 'protection_of_minors';
283+
static const riskForPublicSecurity = 'risk_for_public_security';
284+
static const scamsAndFraud = 'scams_and_fraud';
285+
static const selfHarm = 'self_harm';
286+
static const scopeOfPlatformService = 'scope_of_platform_service';
287+
static const unsafeAndIllegalProducts = 'unsafe_and_illegal_products';
288+
static const violence = 'violence';
271289

272290
static const violationValues = [
273-
'animal_welfare',
274-
'data_protection_and_privacy_violations',
275-
'illegal_or_harmful_speech',
276-
'intellectual_property_infringements',
277-
'negative_effects_on_civic_discourse_or_elections',
278-
'non_consensual_behaviour',
279-
'pornography_or_sexualized_content',
280-
'protection_of_minors',
281-
'risk_for_public_security',
282-
'scams_and_fraud',
283-
'self_harm',
284-
'scope_of_platform_service',
285-
'unsafe_and_illegal_products',
286-
'violence',
291+
animalWelfare,
292+
dataProtectionAndPrivacyViolations,
293+
illegalAndHatefulSpeech,
294+
intellectualPropertyInfringements,
295+
negativeEffectsOnCivicDiscourseOrElections,
296+
nonConsensualBehavior,
297+
pornographyOrSexualizedContent,
298+
protectionOfMinors,
299+
riskForPublicSecurity,
300+
scamsAndFraud,
301+
selfHarm,
302+
scopeOfPlatformService,
303+
unsafeAndIllegalProducts,
304+
violence,
287305
];
288306
}
289307

0 commit comments

Comments
 (0)