Grading a multiple-choice quiz is mechanical. Grading a paragraph a student wrote in their own words is judgment, and judgment is exactly the kind of task an AI model can usefully assist with and should never finalize alone. This lesson builds an ability that takes a student’s free-text answer and a rubric, produces a suggested score and written feedback, and stops there. Nothing is recorded as a real grade without an instructor confirming it.
LearnDash installed and active, with essay or open-ended answer submissions to grade (this lesson’s ability accepts the raw answer text directly, so it works whether that text comes from a LearnDash essay question, an assignment upload’s extracted text, or any other free-text submission you store). Course 5’s Generating Text With wp_ai_client_prompt().
Step 1: Register the ability
Input is a rubric, the student’s answer, and a maximum score. Output is a suggested score and feedback, both explicitly provisional:
// File: lms-tutor/class-grading-ability.php
add_action( 'wp_abilities_api_init', function () {
wp_register_ability(
'lms-tutor/suggest-grade',
array(
'label' => __( 'Suggest a grade for a free-text answer', 'lms-tutor' ),
'description' => __( 'Suggests a score and feedback for a free-text answer against a rubric. Never records a final grade.', 'lms-tutor' ),
'input_schema' => array(
'type' => 'object',
'properties' => array(
'rubric' => array( 'type' => 'string' ),
'student_answer' => array( 'type' => 'string' ),
'max_score' => array( 'type' => 'integer' ),
'submission_id' => array( 'type' => 'integer' ),
),
'required' => array( 'rubric', 'student_answer', 'max_score', 'submission_id' ),
),
'permission_callback' => function () {
return current_user_can( 'edit_others_posts' );
},
'execute_callback' => 'lms_tutor_suggest_grade',
)
);
} );
edit_others_posts is a real, ordinary WordPress capability held by editor-level
accounts and above, standing in here for “someone with instructor-level review rights.”
Adjust it to whatever capability your own instructor role actually carries.
Step 2: Build the rubric-based prompt and parse the response
// File: lms-tutor/class-grading-ability.php
function lms_tutor_suggest_grade( $input ) {
$prompt = <<<PROMPT
Grade the student answer below against the rubric, out of a maximum of {$input['max_score']} points.
Be specific in the feedback about what the answer got right and what it's missing.
Respond in exactly this format:
Score: <number>/{$input['max_score']}
Feedback: <two to three sentences>
Rubric:
{$input['rubric']}
Student answer:
{$input['student_answer']}
PROMPT;
$response = wp_ai_client_prompt( $prompt )->generate_text();
$score = null;
$feedback = '';
if ( preg_match( '/Score:\s*(\d+)/i', $response, $m ) ) {
$score = (int) $m[1];
}
if ( preg_match( '/Feedback:\s*(.+)/is', $response, $m ) ) {
$feedback = trim( $m[1] );
}
if ( null === $score || $score < 0 || $score > $input['max_score'] ) {
$score = null; // Unparseable or out-of-range: no suggested score, not a guessed one.
}
return lms_tutor_store_suggestion( $input['submission_id'], $score, $feedback );
}
An out-of-range or missing score becomes null, not a clamped or guessed number. A
reviewing instructor should see clearly that grading failed to parse, rather than a
plausible-looking score that was never actually validated against the model’s output.
Step 3: Store it as a pending suggestion, not a grade
// File: lms-tutor/class-grading-ability.php
function lms_tutor_store_suggestion( int $submission_id, ?int $score, string $feedback ): array {
global $wpdb;
$wpdb->replace(
$wpdb->prefix . 'lms_tutor_grade_suggestions',
array(
'submission_id' => $submission_id,
'suggested_score' => $score,
'suggested_feedback' => $feedback,
'status' => 'pending_review',
'created_at' => current_time( 'mysql' ),
)
);
return array(
'suggested_score' => $score,
'suggested_feedback' => $feedback,
'status' => 'pending_review',
);
}
status starts and stays pending_review from this ability’s own code, no matter how
confident the model’s output looked. Recording an actual grade against a submission is
a completely separate, explicitly human-triggered action elsewhere in your admin UI,
this ability never calls it.
Test it
wp-env run cli wp eval '
$result = wp_get_ability( "lms-tutor/suggest-grade" )->execute( array(
"rubric" => "Award full points only if the answer explains both the cause and the effect. Partial credit for one or the other.",
"student_answer" => "It happens because pressure increases, which causes the volume to decrease.",
"max_score" => 10,
"submission_id" => 42,
) );
print_r( $result );
'
Confirm status is pending_review and suggested_score falls within the 0 to 10
range, then check the lms_tutor_grade_suggestions table directly to confirm nothing
outside that pending state was ever written.
A well-formatted Score: 9/10 with articulate feedback can feel final enough that a
reviewing instructor rubber-stamps it without actually reading the student’s answer.
That defeats the entire point of keeping a human in the loop. Build your review UI to
show the student’s actual answer alongside the suggestion, not just the suggestion
alone, so approving a grade requires seeing what it’s grading.
Recap
This ability sends a rubric and a student’s free-text answer to generate_text(),
parses a fixed Score:/Feedback: format defensively, and stores the result with a
pending_review status that only this ability’s own code ever writes. An out-of-range
or unparseable score becomes null rather than a guessed number, and no separate,
human-triggered grade-recording action is ever called from inside this ability. The
next lesson turns to a different kind of gate: making the tutor itself a paid,
membership-restricted feature.
Resources & further reading
- PHP AI Client SDK repository, GitHub
- LearnDash Developer Documentation, developers.learndash.com
- Project: Content Moderation Tool, Course 6