Lesson 4 solved the half of an AI-calling ability that’s deterministic, the validation,
the error handling, the shaping of the result. What’s left is the one thing a fake
summarizer deliberately doesn’t cover: whether the real model, given a real prompt,
still produces good output after you’ve changed the prompt, switched providers, or
just watched a few weeks pass. assertSame() can’t grade that, the correct output
this week might be worded differently from the correct output last week. An eval can,
because it scores output against criteria instead of comparing it to one fixed string.
Lesson 4’s dependency-injection pattern (this lesson tests the other side of that same
boundary), and as_json_response() from the prompt engineering course’s structured
output lesson. A working wp_ai_client_prompt() call already returning real output.
Step 1: An eval is a score, not a pass/fail assertion
A PHPUnit test asserts one thing is exactly equal to another. An eval runs a real prompt, gets real output, and checks that output against a set of criteria, some of which might genuinely vary between runs. The right mental model isn’t “did this test pass,” it’s “how many of these N cases still meet the bar, and did that number just drop.” That’s a meaningfully different kind of check, and treating it like a stricter PHPUnit test, expecting 100% pass every single run, is the fastest way to make an eval suite useless: real models have enough inherent variance that a handful of borderline cases flipping between runs is normal, not a regression.
Step 2: Structured output turns scoring into simple field checks
Free-form prose is hard to grade programmatically, “is this summary good” doesn’t
reduce to a line of PHP. as_json_response(), from the prompt engineering course,
sidesteps that by asking for a schema-shaped object instead of prose, which turns
grading into checking specific fields against specific criteria. Reuse the bulk-tagging
example from that course, a post gets a title, up to five tags, and a category
constrained to the site’s real categories:
// File: my-plugin/tests/evals/tagging-eval.php
<?php
$cases = array(
array(
'content' => 'A guide to setting up automated backups for a WordPress site using WP-CLI cron jobs.',
'expect_category_in' => array( 'Tutorials', 'DevOps' ),
'max_tags' => 5,
),
array(
'content' => 'Ten common mistakes beginners make when choosing a WordPress hosting provider.',
'expect_category_in' => array( 'Tutorials', 'Hosting' ),
'max_tags' => 5,
),
);
Each case names an expectation that’s checkable in one line of PHP, not a subjective read of the whole output.
Step 3: Run each case and score it against its own criteria
// File: my-plugin/tests/evals/tagging-eval.php
$categories = get_categories( array( 'hide_empty' => false ) );
$category_names = wp_list_pluck( $categories, 'name' );
$schema = array(
'type' => 'object',
'properties' => array(
'title' => array( 'type' => 'string' ),
'tags' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ), 'maxItems' => 5 ),
'category' => array( 'type' => 'string', 'enum' => $category_names ),
),
'required' => array( 'title', 'tags', 'category' ),
);
$passed = 0;
foreach ( $cases as $i => $case ) {
$raw = wp_ai_client_prompt()
->with_text( "Post content:\n" . $case['content'] )
->using_system_instruction( 'Suggest a concise title, up to five relevant tags, and the single best-fit category. Only choose from the allowed category list.' )
->as_json_response( $schema )
->using_temperature( 0.2 )
->generate_text();
$data = json_decode( $raw, true );
$ok = is_array( $data )
&& in_array( $data['category'] ?? '', $case['expect_category_in'], true )
&& count( $data['tags'] ?? array() ) <= $case['max_tags'];
printf( "[%s] case %d: %s\n", $ok ? 'PASS' : 'FAIL', $i, $ok ? 'ok' : wp_json_encode( $data ) );
$passed += $ok ? 1 : 0;
}
printf( "%d / %d passed\n", $passed, count( $cases ) );
Run it with WP-CLI’s eval-file, since this needs a full WordPress bootstrap and a
real, configured AI provider, it’s not a PHPUnit test:
# File: terminal
wp eval-file tests/evals/tagging-eval.php
Step 4: Set a pass-rate threshold, not a 100% requirement
With a handful of cases, expect some run-to-run variance, especially near a category boundary a model could reasonably read either way. Decide on a threshold before you run this the first time, something like “at least 90% of a 20-case suite must pass,” and treat a drop below that threshold as a real signal worth investigating, a single flipped case in isolation usually isn’t. Grow the case list over time the same way you would a bug-fix regression suite, every real miscategorization you notice in production becomes a new eval case, so the same mistake gets caught automatically going forward.
For output that’s harder to reduce to field-level checks, long-form generated prose being one example, some teams use a second model call to score the first model’s output against a written rubric. That’s a legitimate technique, but it costs a second API call per case and introduces its own variance into the grading itself. Reach for programmatic, field-level scoring like Step 3’s first, since it’s cheaper, faster, and fully deterministic in what it checks, even though the input it’s checking isn’t.
Step 5: Keep evals out of the fast suite that gates every PR
An eval suite makes real API calls, with real cost and real latency, and its output has real, expected variance. None of that belongs in a suite that runs on every commit and blocks a merge on a single red result. Lesson 7 wires the deterministic PHPUnit suite from Lessons 3 and 4 into every pull request, and this eval suite into a separate, less frequent job instead, a nightly schedule or a manual trigger before a meaningful prompt or provider change ships, exactly the distinction Lesson 1 drew between the two problems an AI-calling ability introduces.
Test it: change the prompt and watch the score move
Deliberately loosen the system instruction, drop the “only choose from the allowed category list” sentence, rerun the eval, and confirm the pass rate drops. That drop is the whole point of this suite: catching a prompt change that quietly makes output worse, before it ships, not after a user notices a post filed under a category that doesn’t exist.
Recap
An eval scores real AI output against a rubric instead of comparing it to one fixed
string, and as_json_response() makes that scoring tractable by turning prose into
checkable fields, category membership, tag counts, required keys present. Set a
pass-rate threshold rather than expecting every case to pass every run, grow the case
list from real production misses, and keep this suite entirely separate from the fast,
deterministic PHPUnit suite that gates every pull request, since it has a different
cost profile and a different kind of pass/fail entirely.
Resources & further reading
- PHP AI Client SDK repository, GitHub
- WP-CLI eval-file command, developer.wordpress.org
- Introducing the AI Client in WordPress 7.0, make.wordpress.org