Skip to content

Commit 0a7405d

Browse files
committed
Add Comment Moderation experiment
Adds AI-powered comment moderation with: - Toxicity scoring and sentiment analysis badges in Comments list - Lazy analysis that processes pending comments on page load - Bulk action to queue multiple comments for analysis - AI Reply suggestions modal with tone selection - Comment meta storage for analysis results Includes shared run-ability.ts utility for Abilities API fallback and get_model_preferences() method for model selection.
1 parent 0e9c94b commit 0a7405d

File tree

14 files changed

+2090
-0
lines changed

14 files changed

+2090
-0
lines changed
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Comment Moderation
2+
3+
## Summary
4+
Adds AI-powered sentiment analysis, toxicity scoring, and reply suggestions to the classic Comments screen. Moderators can see badges directly in `edit-comments.php`, run bulk analysis, and request suggested replies without leaving wp-admin.
5+
6+
## Key Hooks & Entry Points
7+
- `WordPress\AI\Experiments\Comment_Moderation\Comment_Moderation::register()` wires everything once the experiment is enabled:
8+
- `wp_abilities_api_init` → registers `ai/comment-analysis` and `ai/reply-suggestion` abilities (`includes/Abilities/Comment_Moderation/*.php`).
9+
- `manage_edit-comments_columns`, `manage_comments_custom_column` → inject sentiment/toxicity columns.
10+
- `bulk_actions-edit-comments`, `handle_bulk_actions-edit-comments`, `admin_notices` → add the “Analyze with AI” bulk flow and status notices.
11+
- `comment_row_actions` → adds the “AI Reply” row action.
12+
- `admin_enqueue_scripts` → enqueues the React bundle on `edit-comments.php`.
13+
- `admin_head-edit-comments.php` → prints inline badge styles so they render even when JS fails.
14+
- REST and comment-meta updates happen via the two abilities; the experiment itself only orchestrates UI + enqueue points.
15+
16+
## Assets & Data Flow
17+
1. `enqueue_assets()` loads `experiments/comment-moderation` (`src/experiments/comment-moderation/index.tsx`) and localizes `window.CommentModerationData` with `enabled` + nonce.
18+
2. The React entry mounts two controllers:
19+
- `LazyAnalysisController` polls for comments that need analysis and calls `runAbility( 'ai/comment-analysis' )`, updating comment meta and refreshing rows in place.
20+
- `ReplyModalController` opens a modal when an “AI Reply” row action is clicked, calling `runAbility( 'ai/reply-suggestion' )` to fetch draft replies the moderator can paste.
21+
3. Both controllers rely on the shared `run-ability.ts` helper so they can use the Abilities API client when available or fall back to REST calls.
22+
4. Ability responses are persisted via comment meta (`_ai_toxicity_score`, `_ai_sentiment`, `_ai_analysis_status`, `_ai_analyzed_at`), which the PHP column renderers read to display badges.
23+
24+
## Testing
25+
1. Enable Experiments globally and toggle **Comment Moderation** under `Settings → AI Experiments`.
26+
2. Visit `Comments → All Comments`. Pending comments should show “Analyze with AI” badges; clicking one should enqueue an analysis request and update the badge once complete.
27+
3. Select multiple comments, choose the “Analyze with AI” bulk action, and confirm the inline notice reports how many were queued.
28+
4. Approve a comment and click its “AI Reply” row action. The modal should display suggested replies; applying one should copy it into the WordPress reply form.
29+
5. Toggle the experiment off and reload the page—columns, badges, row/bulk actions, and scripts should disappear.
30+
31+
## Notes
32+
- The experiment only runs for users with `moderate_comments`.
33+
- Analysis locks each comment while it is processing to prevent duplicate requests.
34+
- Replies and analysis rely on AI credentials; without valid credentials the whole experiment remains disabled via the shared experiment toggle logic.
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
<?php
2+
/**
3+
* Comment Analysis WordPress Ability implementation.
4+
*
5+
* @package WordPress\AI
6+
*/
7+
8+
declare( strict_types=1 );
9+
10+
namespace WordPress\AI\Abilities\Comment_Moderation;
11+
12+
use WP_Error;
13+
use WordPress\AI\Abstracts\Abstract_Ability;
14+
use WordPress\AI\Experiments\Comment_Moderation\Comment_Moderation;
15+
use WordPress\AI_Client\AI_Client;
16+
17+
/**
18+
* Comment Analysis WordPress Ability.
19+
*
20+
* Analyzes comments for toxicity and sentiment using AI.
21+
*
22+
* @since 0.1.0
23+
*/
24+
class Comment_Analysis extends Abstract_Ability {
25+
26+
/**
27+
* Returns the input schema of the ability.
28+
*
29+
* @since 0.1.0
30+
*
31+
* @return array<string, mixed> The input schema of the ability.
32+
*/
33+
protected function input_schema(): array {
34+
return array(
35+
'type' => 'object',
36+
'properties' => array(
37+
'comment_id' => array(
38+
'type' => 'integer',
39+
'sanitize_callback' => 'absint',
40+
'description' => esc_html__( 'The ID of the comment to analyze.', 'ai' ),
41+
'required' => true,
42+
),
43+
),
44+
'required' => array( 'comment_id' ),
45+
);
46+
}
47+
48+
/**
49+
* Returns the output schema of the ability.
50+
*
51+
* @since 0.1.0
52+
*
53+
* @return array<string, mixed> The output schema of the ability.
54+
*/
55+
protected function output_schema(): array {
56+
return array(
57+
'type' => 'object',
58+
'properties' => array(
59+
'comment_id' => array(
60+
'type' => 'integer',
61+
'description' => esc_html__( 'The analyzed comment ID.', 'ai' ),
62+
),
63+
'toxicity_score' => array(
64+
'type' => 'number',
65+
'minimum' => 0,
66+
'maximum' => 1,
67+
'description' => esc_html__( 'Toxicity score from 0 (not toxic) to 1 (highly toxic).', 'ai' ),
68+
),
69+
'sentiment' => array(
70+
'type' => 'string',
71+
'enum' => array( 'positive', 'negative', 'neutral' ),
72+
'description' => esc_html__( 'The sentiment of the comment.', 'ai' ),
73+
),
74+
),
75+
);
76+
}
77+
78+
/**
79+
* Executes the ability with the given input arguments.
80+
*
81+
* @since 0.1.0
82+
*
83+
* @param mixed $input The input arguments to the ability.
84+
* @return array{comment_id: int, toxicity_score: float, sentiment: string}|\WP_Error The result of the ability execution.
85+
*/
86+
protected function execute_callback( $input ) {
87+
$comment_id = absint( $input['comment_id'] ?? 0 );
88+
89+
if ( ! $comment_id ) {
90+
return new WP_Error(
91+
'missing_comment_id',
92+
esc_html__( 'Comment ID is required.', 'ai' )
93+
);
94+
}
95+
96+
$comment = get_comment( $comment_id );
97+
98+
if ( ! $comment ) {
99+
return new WP_Error(
100+
'comment_not_found',
101+
sprintf(
102+
/* translators: %d: Comment ID. */
103+
esc_html__( 'Comment with ID %d not found.', 'ai' ),
104+
$comment_id
105+
)
106+
);
107+
}
108+
109+
// Check if already being processed (lock mechanism).
110+
$current_status = get_comment_meta( $comment_id, Comment_Moderation::META_ANALYSIS_STATUS, true );
111+
112+
if ( Comment_Moderation::STATUS_PROCESSING === $current_status ) {
113+
return new WP_Error(
114+
'already_processing',
115+
esc_html__( 'This comment is already being analyzed.', 'ai' )
116+
);
117+
}
118+
119+
// Set status to processing.
120+
update_comment_meta( $comment_id, Comment_Moderation::META_ANALYSIS_STATUS, Comment_Moderation::STATUS_PROCESSING );
121+
122+
// Analyze the comment.
123+
$result = $this->analyze_comment( $comment->comment_content, $comment->comment_author );
124+
125+
if ( is_wp_error( $result ) ) {
126+
// Mark as failed.
127+
update_comment_meta( $comment_id, Comment_Moderation::META_ANALYSIS_STATUS, Comment_Moderation::STATUS_FAILED );
128+
return $result;
129+
}
130+
131+
// Store the results.
132+
update_comment_meta( $comment_id, Comment_Moderation::META_TOXICITY_SCORE, $result['toxicity_score'] );
133+
update_comment_meta( $comment_id, Comment_Moderation::META_SENTIMENT, $result['sentiment'] );
134+
update_comment_meta( $comment_id, Comment_Moderation::META_ANALYSIS_STATUS, Comment_Moderation::STATUS_COMPLETE );
135+
update_comment_meta( $comment_id, Comment_Moderation::META_ANALYZED_AT, time() );
136+
137+
return array(
138+
'comment_id' => $comment_id,
139+
'toxicity_score' => $result['toxicity_score'],
140+
'sentiment' => $result['sentiment'],
141+
);
142+
}
143+
144+
/**
145+
* Returns the permission callback of the ability.
146+
*
147+
* @since 0.1.0
148+
*
149+
* @param mixed $input The input arguments to the ability.
150+
* @return bool|\WP_Error True if the user has permission, WP_Error otherwise.
151+
*/
152+
protected function permission_callback( $input ) {
153+
if ( ! current_user_can( 'moderate_comments' ) ) {
154+
return new WP_Error(
155+
'insufficient_capabilities',
156+
esc_html__( 'You do not have permission to analyze comments.', 'ai' )
157+
);
158+
}
159+
160+
return true;
161+
}
162+
163+
/**
164+
* Returns the meta of the ability.
165+
*
166+
* @since 0.1.0
167+
*
168+
* @return array<string, mixed> The meta of the ability.
169+
*/
170+
protected function meta(): array {
171+
return array(
172+
'show_in_rest' => true,
173+
);
174+
}
175+
176+
/**
177+
* Analyzes a comment for toxicity and sentiment.
178+
*
179+
* @since 0.1.0
180+
*
181+
* @param string $content The comment content.
182+
* @param string $author The comment author name.
183+
* @return array{toxicity_score: float, sentiment: string}|\WP_Error The analysis result.
184+
*/
185+
private function analyze_comment( string $content, string $author ) {
186+
$prompt = sprintf(
187+
"Comment by %s:\n\"\"\"%s\"\"\"",
188+
$author,
189+
$content
190+
);
191+
192+
$result = AI_Client::prompt_with_wp_error( $prompt )
193+
->using_system_instruction( $this->get_system_instruction() )
194+
->using_model_preference( ...$this->get_model_preferences() )
195+
->generate_text();
196+
197+
if ( is_wp_error( $result ) ) {
198+
return $result;
199+
}
200+
201+
// Parse the JSON response.
202+
$parsed = json_decode( $result, true );
203+
204+
if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $parsed ) ) {
205+
return new WP_Error(
206+
'parse_error',
207+
esc_html__( 'Failed to parse AI response.', 'ai' )
208+
);
209+
}
210+
211+
// Validate and sanitize the response.
212+
$toxicity_score = isset( $parsed['toxicity_score'] )
213+
? max( 0, min( 1, (float) $parsed['toxicity_score'] ) )
214+
: 0;
215+
216+
$valid_sentiments = array( 'positive', 'negative', 'neutral' );
217+
$sentiment = isset( $parsed['sentiment'] ) && in_array( $parsed['sentiment'], $valid_sentiments, true )
218+
? $parsed['sentiment']
219+
: 'neutral';
220+
221+
return array(
222+
'toxicity_score' => $toxicity_score,
223+
'sentiment' => $sentiment,
224+
);
225+
}
226+
}

0 commit comments

Comments
 (0)