Quality Control

Human Review Gates for Translation Quality

⏱ 15 min

Every ability built so far in this course, whether it’s Lesson 2’s WPML translation, Lesson 3’s Polylang equivalent, or Lesson 6’s product catalog batch, ends the same way: post_status => 'draft'. That’s the same rule Course 6’s original translation project established, and it’s not a formality. This lesson turns “it’s a draft” into an actual reviewed workflow: a queue a bilingual reviewer can see, and an explicit approval ability that’s the only thing allowed to move a translation from draft to published.

What you'll learn in this lesson
Why draft status alone doesn't guarantee a review happened
A draft can sit untouched or get published by an unrelated automation just as easily as it can get reviewed.
Building a pending-translations queue
A read-only ability that surfaces every draft translation waiting on a bilingual reviewer.
An explicit approval ability, requiring a reviewer to confirm the review happened
The single, deliberate step that's allowed to publish a translation, and nothing else is.
Why this connects directly to Course 25's human-in-the-loop pattern
The same five-stage discipline, applied here to translation instead of original drafting.
Prerequisites

Lesson 2 or Lesson 3 (or Lesson 6’s batch ability), this lesson wraps a review queue and approval gate around whichever of those you’ve already built, it doesn’t introduce a new translation mechanism. Course 25’s human-in-the-loop editing lesson for the underlying pattern this lesson applies to translation specifically.

Step 1: Draft status is necessary, but it isn’t sufficient

Every ability in this course creates translations as drafts, and that’s the right default, exactly the same reasoning Course 6’s project and every project since have followed: machine translation can mistranslate idioms, get formal or informal register wrong, or mishandle a term that shouldn’t have been translated at all. But draft on its own is just a status, nothing stops a well-meaning but different automation, a cron job, a bulk “publish all drafts in this category” script, from moving it to publish without anyone bilingual ever reading it. A real review gate needs a queue someone actually looks at, and an approval step that’s the only path to publish.

Never build a follow-up automation that auto-publishes translation drafts

It’s tempting, once a translation pipeline is running reliably, to add a scheduled task that publishes translated drafts automatically after some number of days if nobody’s touched them. Don’t. That silently removes the one safeguard this entire course has built around every ability, an unreviewed AI translation reaching a live, public, non-source-language page. If a reviewer backlog is the real problem, solve that by adding reviewers or narrowing what gets translated, not by removing the review step.

Step 2: Build the pending-translations queue

This ability is deliberately read-only, it surfaces what needs review, it never changes anything.

// File: translation-review/translation-review.php
add_action( 'wp_abilities_api_init', function () {
	wp_register_ability(
		'translation-review/list-pending',
		array(
			'label'               => __( 'List pending translations', 'translation-review' ),
			'description'         => __( 'Lists draft posts and products created by this course\'s translation abilities, awaiting bilingual reviewer approval. Changes nothing.', 'translation-review' ),
			'category'            => 'content',
			'input_schema'        => array(
				'type'       => 'object',
				'properties' => array(
					'post_type' => array( 'type' => 'string', 'default' => 'post' ),
				),
			),
			'output_schema'       => array(
				'type'  => 'array',
				'items' => array(
					'type'       => 'object',
					'properties' => array(
						'post_id'  => array( 'type' => 'integer' ),
						'title'    => array( 'type' => 'string' ),
						'language' => array( 'type' => 'string' ),
						'edit_url' => array( 'type' => 'string' ),
					),
				),
			),
			'permission_callback' => function () {
				return current_user_can( 'edit_others_posts' );
			},
			'execute_callback'    => 'translation_review_list_pending',
			'meta' => array(
				'annotations' => array( 'readonly' => true ),
				'mcp'         => array( 'public' => true ),
			),
		)
	);
} );

function translation_review_list_pending( $input ) {
	$post_type = ! empty( $input['post_type'] ) ? sanitize_key( $input['post_type'] ) : 'post';

	$drafts = get_posts( array(
		'post_type'      => $post_type,
		'post_status'    => 'draft',
		'posts_per_page' => -1,
	) );

	$pending = array();

	foreach ( $drafts as $draft ) {
		$language = translation_review_get_language( $draft->ID );
		if ( ! $language ) {
			continue; // Not a translation this queue tracks, an ordinary unrelated draft.
		}

		$pending[] = array(
			'post_id'  => $draft->ID,
			'title'    => $draft->post_title,
			'language' => $language,
			'edit_url' => get_edit_post_link( $draft->ID, 'raw' ),
		);
	}

	return $pending;
}

function translation_review_get_language( $post_id ) {
	if ( function_exists( 'apply_filters' ) && has_filter( 'wpml_post_language_details' ) ) {
		$details = apply_filters( 'wpml_post_language_details', null, $post_id );
		return $details['language_code'] ?? null;
	}

	if ( function_exists( 'pll_get_post_language' ) ) {
		return pll_get_post_language( $post_id );
	}

	return get_post_meta( $post_id, '_translation_language', true ) ?: null; // Course 6's fallback.
}

Step 3: Build the approval ability, the only path to publish

The approval ability requires the caller to explicitly confirm a review happened, not just call an endpoint that happens to publish. A boolean the caller has to set deliberately makes it harder to trigger by accident, the same reasoning Course 6’s bulk-editing project applied to its preview/commit split.

// File: translation-review/translation-review.php
add_action( 'wp_abilities_api_init', function () {
	wp_register_ability(
		'translation-review/approve',
		array(
			'label'               => __( 'Approve a reviewed translation', 'translation-review' ),
			'description'         => __( 'Publishes a translated draft. Requires reviewer_confirmed to be explicitly true, refuses to publish otherwise. This is the only ability in this course that publishes a translation.', 'translation-review' ),
			'category'            => 'content',
			'input_schema'        => array(
				'type'       => 'object',
				'properties' => array(
					'post_id'            => array( 'type' => 'integer' ),
					'reviewer_confirmed' => array( 'type' => 'boolean' ),
				),
				'required'   => array( 'post_id', 'reviewer_confirmed' ),
			),
			'output_schema'       => array(
				'type'       => 'object',
				'properties' => array( 'published' => array( 'type' => 'boolean' ) ),
			),
			'permission_callback' => function ( $input ) {
				return current_user_can( 'publish_posts' ) && current_user_can( 'edit_post', $input['post_id'] );
			},
			'execute_callback'    => 'translation_review_approve',
			'meta' => array(
				'annotations' => array( 'destructive' => true, 'idempotent' => true ),
				'mcp'         => array( 'public' => true ),
			),
		)
	);
} );

function translation_review_approve( $input ) {
	if ( true !== $input['reviewer_confirmed'] ) {
		return new WP_Error( 'not_confirmed', 'reviewer_confirmed must be explicitly true. This ability never publishes without it.' );
	}

	$post = get_post( $input['post_id'] );
	if ( ! $post || 'draft' !== $post->post_status ) {
		return new WP_Error( 'not_pending', 'This post is not a pending draft translation.' );
	}

	$result = wp_update_post( array(
		'ID'          => $post->ID,
		'post_status' => 'publish',
	), true );

	if ( is_wp_error( $result ) ) {
		return $result;
	}

	return array( 'published' => true );
}

meta.annotations.destructive is true here, same reasoning Course 6’s bulk-edit commit step used, this is the one ability in the whole course that can make something public, and it should read as the deliberate action it is to any MCP client or agent inspecting it.

Step 4: Wiring it into a real editorial routine

A bilingual reviewer runs list-pending regularly, not just when reminded
Build it into whatever cadence your editorial team already uses for reviewing drafts.
The reviewer actually reads the translation against the source, in both languages
The same distinction Course 25 drew between "read for tone" and real review applies here, checking fluency isn't the same as confirming terminology and meaning survived the translation intact.
Only after that reading does the reviewer call approve, with reviewer_confirmed set to true
That flag is a statement the reviewer is making, not a formality to check past.
Rejected translations get edited or regenerated, not silently deleted
Keep a record of what didn't pass review, it's useful signal for tightening Lesson 4's glossary over time.

Test it

wp-env run cli wp eval '
$pending = wp_get_ability( "translation-review/list-pending" )->execute( array( "post_type" => "post" ) );
print_r( $pending );
'

Pick one post ID from the output, read it against its source in wp-admin, then approve it:

wp-env run cli wp eval '
$result = wp_get_ability( "translation-review/approve" )->execute( array(
	"post_id"            => 55,
	"reviewer_confirmed" => true,
) );
print_r( $result );
'

Confirm the post’s status is now publish, and confirm calling approve again with reviewer_confirmed omitted or false returns the not_confirmed error rather than publishing anything.

Recap

translation-review/list-pending surfaces every draft translation waiting on a bilingual reviewer without changing anything, and translation-review/approve is the single, explicit, destructive-annotated ability that can move one from draft to published, and only when reviewer_confirmed is set to true. This is the same human-in-the-loop discipline Course 25 built for original content, applied here to translation: a clear job at the review stage, and no path from AI output straight to a public, non-source-language page without it.

Resources & further reading

← Translating Structured Content and Product Catalogs Course Recap: An AI Translation System That Keeps Quality and Rankings Intact →