Skip to content

Commit 4a73f21

Browse files
authored
Admin action to generate report for moderation transparency metrics. (#8096)
1 parent f13d303 commit 4a73f21

File tree

4 files changed

+646
-14
lines changed

4 files changed

+646
-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_reservation_create.dart';
2223
import 'package_version_info.dart';
@@ -98,6 +99,7 @@ final class AdminAction {
9899
moderationCaseList,
99100
moderationCaseResolve,
100101
moderationCaseUpdate,
102+
moderationTransparencyMetrics,
101103
packageInfo,
102104
packageReservationCreate,
103105
packageVersionInfo,
Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
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 actions resulted in a user being blocked, then we count it as
86+
// "provision", even if packages were also removed.
87+
// Reasoning that it's natural that blocking a user would also
88+
// remove some of the users content.
89+
if (hasUserRestriction) {
90+
restrictions.increment('provision');
91+
} else {
92+
restrictions.increment('visibility');
93+
}
94+
}
95+
96+
// Report group #2: appeals.
97+
if (mc.appealedCaseId != null) {
98+
totalAppealCount++;
99+
if (mc.isSubjectOwner) {
100+
contentOwnerAppealCount++;
101+
}
102+
103+
switch (mc.status) {
104+
case ModerationStatus.noActionUpheld:
105+
case ModerationStatus.moderationUpheld:
106+
appealOutcomes.increment('upheld');
107+
break;
108+
case ModerationStatus.noActionReverted:
109+
case ModerationStatus.moderationReverted:
110+
appealOutcomes.increment('reverted');
111+
break;
112+
default:
113+
appealOutcomes.increment('omitted');
114+
break;
115+
}
116+
117+
final timeToActionDays = mc.resolved!.difference(mc.opened).inDays + 1;
118+
appealTimeToActionDays.add(timeToActionDays);
119+
}
120+
}
121+
122+
appealTimeToActionDays.sort();
123+
final appealMedianTimeToActionDays = appealTimeToActionDays.isEmpty
124+
? 0
125+
: appealTimeToActionDays[appealTimeToActionDays.length ~/ 2];
126+
127+
final userQuery = dbService.query<User>()
128+
// timestamp start on 00:00:00 on the day
129+
..filter('moderatedAt >=', start)
130+
// adding an extra day to make sure the end day is fully included
131+
..filter('moderatedAt <', end.add(Duration(days: 1)));
132+
final reasonCounts = <String, int>{};
133+
await for (final user in userQuery.run()) {
134+
// sanity check
135+
if (user.moderatedAt == null) {
136+
continue;
137+
}
138+
139+
// Report group #3: user restrictions.
140+
reasonCounts.increment(user.moderatedReason ?? '');
141+
}
142+
143+
final text = toCsV([
144+
// ---------------------------------------
145+
['Restrictive actions', ''],
146+
['Total number of actions taken', totalModerationCount],
147+
[
148+
'Number of actions taken, by type of illegal content or violation of terms and conditions',
149+
'',
150+
],
151+
[
152+
'VIOLATION_CATEGORY_ANIMAL_WELFARE',
153+
violations[ModerationViolation.animalWelfare] ?? 0,
154+
],
155+
[
156+
'VIOLATION_CATEGORY_DATA_PROTECTION_AND_PRIVACY_VIOLATIONS',
157+
violations[ModerationViolation.dataProtectionAndPrivacyViolations] ?? 0,
158+
],
159+
[
160+
'VIOLATION_CATEGORY_ILLEGAL_OR_HARMFUL_SPEECH',
161+
violations[ModerationViolation.illegalAndHatefulSpeech] ?? 0,
162+
],
163+
[
164+
'VIOLATION_CATEGORY_INTELLECTUAL_PROPERTY_INFRINGEMENTS',
165+
violations[ModerationViolation.intellectualPropertyInfringements] ?? 0,
166+
],
167+
[
168+
'VIOLATION_CATEGORY_NEGATIVE_EFFECTS_ON_CIVIC_DISCOURSE_OR_ELECTIONS',
169+
violations[ModerationViolation
170+
.negativeEffectsOnCivicDiscourseOrElections] ??
171+
0,
172+
],
173+
[
174+
'VIOLATION_CATEGORY_NON_CONSENSUAL_BEHAVIOUR',
175+
violations[ModerationViolation.nonConsensualBehavior] ?? 0,
176+
],
177+
[
178+
'VIOLATION_CATEGORY_PORNOGRAPHY_OR_SEXUALIZED_CONTENT',
179+
violations[ModerationViolation.pornographyOrSexualizedContent] ?? 0,
180+
],
181+
[
182+
'VIOLATION_CATEGORY_PROTECTION_OF_MINORS',
183+
violations[ModerationViolation.protectionOfMinors] ?? 0,
184+
],
185+
[
186+
'VIOLATION_CATEGORY_RISK_FOR_PUBLIC_SECURITY',
187+
violations[ModerationViolation.riskForPublicSecurity] ?? 0,
188+
],
189+
[
190+
'VIOLATION_CATEGORY_SCAMS_AND_FRAUD',
191+
violations[ModerationViolation.scamsAndFraud] ?? 0,
192+
],
193+
[
194+
'VIOLATION_CATEGORY_SELF_HARM',
195+
violations[ModerationViolation.selfHarm] ?? 0,
196+
],
197+
[
198+
'VIOLATION_CATEGORY_SCOPE_OF_PLATFORM_SERVICE',
199+
violations[ModerationViolation.scopeOfPlatformService] ?? 0,
200+
],
201+
[
202+
'VIOLATION_CATEGORY_UNSAFE_AND_ILLEGAL_PRODUCTS',
203+
violations[ModerationViolation.unsafeAndIllegalProducts] ?? 0,
204+
],
205+
[
206+
'VIOLATION_CATEGORY_VIOLENCE',
207+
violations[ModerationViolation.violence] ?? 0,
208+
],
209+
['Number of actions taken, by detection method', ''],
210+
[
211+
'Automated detection',
212+
sources[ModerationSource.automatedDetection] ?? 0
213+
],
214+
[
215+
'Non-automated detection',
216+
sources.entries
217+
.where((e) => e.key != ModerationSource.automatedDetection)
218+
.map((e) => e.value)
219+
.fold<int>(0, (a, b) => a + b)
220+
],
221+
['Number of actions taken, by type of restriction applied', ''],
222+
[
223+
'Restrictions of Visibility',
224+
restrictions['visibility'] ?? 0,
225+
],
226+
[
227+
'Restrictions of Monetisation',
228+
restrictions['monetisation'] ?? 0,
229+
],
230+
[
231+
'Restrictions of Provision of the Service',
232+
restrictions['provision'] ?? 0,
233+
],
234+
[
235+
'Restrictions of an Account',
236+
restrictions['account'] ?? 0,
237+
],
238+
239+
// ---------------------------------------
240+
['Complaints received through internal complaint handling systems', ''],
241+
['Total number of complaints received', totalAppealCount],
242+
['Number of complaints received, by reason for complaint', ''],
243+
['CONTENT_ACCOUNT_OWNER_APPEAL', contentOwnerAppealCount],
244+
['REPORTER_APPEAL', totalAppealCount - contentOwnerAppealCount],
245+
['Number of complaints received, by outcome', ''],
246+
[
247+
'Initial decision upheld',
248+
appealOutcomes['upheld'] ?? 0,
249+
],
250+
[
251+
'Initial decision reversed',
252+
appealOutcomes['reverted'] ?? 0,
253+
],
254+
[
255+
'Decision omitted',
256+
appealOutcomes['omitted'] ?? 0,
257+
],
258+
[
259+
'Median time to action a complaint (days)',
260+
appealMedianTimeToActionDays,
261+
],
262+
263+
// ---------------------------------------
264+
['Suspensions imposed to protect against misuse', ''],
265+
[
266+
'Number of suspensions for manifestly illegal content imposed pursuant to Article 23',
267+
reasonCounts[UserModeratedReason.illegalContent] ?? 0,
268+
],
269+
[
270+
'Number of suspensions for manifestly unfounded notices imposed pursuant to Article 23',
271+
reasonCounts[UserModeratedReason.unfoundedNotifications] ?? 0,
272+
],
273+
[
274+
'Number of suspensions for manifestly unfounded complaints imposed pursuant to Article 23',
275+
reasonCounts[UserModeratedReason.unfoundedAppeals] ?? 0,
276+
],
277+
]);
278+
279+
return {
280+
'text': text,
281+
'moderations': {
282+
'total': totalModerationCount,
283+
'violations': violations,
284+
'sources': sources,
285+
'restrictions': restrictions,
286+
},
287+
'appeals': {
288+
'total': totalAppealCount,
289+
'contentOwner': contentOwnerAppealCount,
290+
'outcomes': appealOutcomes,
291+
'medianTimeToActionDays': appealMedianTimeToActionDays,
292+
},
293+
'users': {
294+
'suspensions': reasonCounts,
295+
}
296+
};
297+
},
298+
);
299+
300+
/// Loose implementation of RFC 4180 writing tabular data into Comma Separated Values.
301+
/// The current implementation supports only String and int values.
302+
String toCsV(List<List<Object>> data) {
303+
final sb = StringBuffer();
304+
for (final row in data) {
305+
for (var i = 0; i < row.length; i++) {
306+
if (i > 0) {
307+
sb.write(',');
308+
}
309+
final value = row[i];
310+
if (value is int) {
311+
sb.write(value);
312+
} else if (value is String) {
313+
final mustEscape = value.contains(',') ||
314+
value.contains('"') ||
315+
value.contains('\r') ||
316+
value.contains('\n');
317+
sb.write(mustEscape ? '"${value.replaceAll('"', '""')}"' : value);
318+
} else {
319+
throw UnimplementedError(
320+
'Unhandled CSV type: ${value.runtimeType}/$value');
321+
}
322+
}
323+
sb.write('\r\n');
324+
}
325+
return sb.toString();
326+
}
327+
328+
extension on Map<String, int> {
329+
void increment(String key) {
330+
this[key] = (this[key] ?? 0) + 1;
331+
}
332+
}

0 commit comments

Comments
 (0)