Skip to content

Commit bee218f

Browse files
committed
wip
1 parent 722918a commit bee218f

1 file changed

Lines changed: 119 additions & 26 deletions

File tree

evals.md

Lines changed: 119 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ To get started, require the plugin via Composer:
1717
composer require pestphp/pest-plugin-evals --dev
1818
```
1919

20-
The built-in scorers evaluate output using [Laravel AI](https://github.com/laravel/ai). To use them, install the package and make sure an API key is available for your provider (OpenAI by default):
20+
That is all you need for deterministic checks such as `toContain()`, `toHaveToolCalls()`, and `toFollowTrajectory()`.
21+
22+
The AI-powered scorers — relevance, safety, factuality, LLM-as-judge, and semantic similarity — need two capabilities: a way to send a prompt to a *judge* model, and a way to turn text into *embeddings*. The plugin calls these capabilities [drivers](#drivers). Out of the box it ships drivers backed by [Laravel AI](https://github.com/laravel/ai), so the quickest way to get running is to install it:
2123

2224
```bash
2325
composer require laravel/ai --dev
@@ -28,6 +30,8 @@ composer require laravel/ai --dev
2830
OPENAI_API_KEY=your-key-here
2931
```
3032

33+
You are **not** tied to Laravel AI, however. The drivers are pluggable — you can point them at Anthropic, a self-hosted model, another SDK, or even a deterministic stub without ever installing `laravel/ai`. See [Drivers](#drivers) for the details.
34+
3135
---
3236

3337
## Writing Your First Eval
@@ -79,7 +83,7 @@ When you run with `--evals`, Pest prints a summary of how many evals passed alon
7983

8084
## Prompting
8185

82-
The `prompt()` method accepts any class implementing Laravel AI's `Agent` contract (as a class name or an instance), or a plain closure for lightweight tasks that don't warrant a dedicated agent class:
86+
The `prompt()` method accepts any class implementing Laravel AI's `Agent` contract (as a class name or an instance), or a plain closure. The closure form means the thing *under test* is not tied to any particular SDK — anything that turns a string prompt into a string response can be evaluated, including your own HTTP client or a different AI library:
8387

8488
```php
8589
expect(fn (string $input): string => generate_answer($input))
@@ -101,7 +105,7 @@ expect(VisionAgent::class)
101105

102106
## Deterministic Expectations
103107

104-
When part of the response is predictable, you may assert against it directly. These checks make no additional AI calls beyond the agent's response:
108+
When part of the response is predictable, you may assert against it directly. These checks make no additional AI calls beyond the agent's response, and they need no [driver](#drivers):
105109

106110
```php
107111
expect(CapitalCityAgent::class)
@@ -134,6 +138,8 @@ it('is consistent across multiple samples', function (): void {
134138

135139
Deterministic checks can only take you so far. To evaluate qualities like relevance, safety, or factual accuracy, the plugin ships a set of scorers that grade the output on a scale from `0.0` to `1.0`. Each scorer accepts a `threshold` (defaulting to `0.7`) and fails the eval if the score falls below it.
136140

141+
These scorers do their grading through the plugin's [drivers](#drivers): the LLM-as-judge scorers use the judge driver, while `toBeSimilar()` uses the embeddings driver. Both default to Laravel AI but can be swapped for any backend.
142+
137143
### `toBeRelevant()`
138144

139145
Asserts that the response is relevant to the prompt:
@@ -187,7 +193,7 @@ expect(GreetingAgent::class)
187193

188194
### `toHaveToolCalls()`
189195

190-
Asserts that the agent invoked the expected tools. Provide an array keyed by tool name, with either the expected arguments or a closure to validate them:
196+
Asserts that the agent invoked the expected tools. Provide an array keyed by tool name, with either the expected arguments or a closure to validate them. This check is deterministic — it parses the tool calls from the output and needs no driver:
191197

192198
```php
193199
expect(WeatherAgent::class)
@@ -252,32 +258,27 @@ expect(GreetingAgent::class)
252258
->toPassScorer(new WordCountScorer(maxWords: 30));
253259
```
254260

255-
---
261+
A scorer decides *what* to measure. If your scorer needs to reach an LLM or produce embeddings to do its measuring, it should go through the [drivers](#drivers) rather than calling a provider directly — that way it inherits whatever backend the project has configured.
256262

257-
<a name="running-evals"></a>
258-
## Running Evals
263+
---
259264

260-
Because every eval calls a real model, evals are **skipped by default**. This keeps your everyday `pest` run fast, free, and deterministic — your evals live alongside your other tests without ever calling an API or slowing the suite down.
265+
<a name="drivers"></a>
266+
## Drivers
261267

262-
When you want to actually evaluate your agents, opt in with `--evals`:
268+
The AI-powered scorers do not talk to a model directly. Instead, they delegate to two small, single-method **drivers** — one for judging, one for embeddings. This indirection is what makes the scorers provider-agnostic: swap the driver and every scorer follows, without touching a single eval.
263269

264-
```bash
265-
./vendor/bin/pest # evals are skipped
266-
./vendor/bin/pest --evals # evals run against the real model
267-
```
270+
There are two driver contracts:
268271

269-
This applies to every target, including closures — an eval only runs under `--evals`. You may also force eval mode with the `PEST_EVALS` environment variable, which is convenient in CI:
272+
| Contract | Method | Powers |
273+
| --- | --- | --- |
274+
| `Pest\Evals\Contracts\JudgeDriver` | `generate(string $instructions, string $prompt): string` | `toBeRelevant()`, `toBeSafe()`, `toBeFactual()`, `toPassJudge()`, and any judge-based custom scorer |
275+
| `Pest\Evals\Contracts\EmbeddingsDriver` | `embed(array $inputs): array` | `toBeSimilar()` and any embeddings-based custom scorer |
270276

271-
```bash
272-
PEST_EVALS=1 ./vendor/bin/pest
273-
```
277+
The deterministic checks (`toContain()`, `toBe()`, `toHaveToolCalls()`, `toFollowTrajectory()`, …) use no driver at all — they inspect the output directly.
274278

275-
---
279+
### The Default: Laravel AI
276280

277-
<a name="configuration"></a>
278-
## Configuration
279-
280-
By default, the scorers judge and embed output using OpenAI via Laravel AI. The simplest way to change the provider or model is through environment variables, which is convenient for switching providers between environments:
281+
Unless you say otherwise, the plugin uses `LaravelAiJudge` and `LaravelAiEmbeddings`, which call OpenAI through Laravel AI. The simplest way to change the provider or model is through environment variables, which is convenient for switching providers between environments:
281282

282283
```ini
283284
PEST_EVALS_LARAVEL_SCORING_PROVIDER=openai
@@ -286,7 +287,7 @@ PEST_EVALS_LARAVEL_EMBEDDING_PROVIDER=openai
286287
PEST_EVALS_LARAVEL_EMBEDDING_MODEL=text-embedding-3-small
287288
```
288289

289-
Alternatively, you may configure the drivers explicitly within your `tests/Pest.php` file using `pest()->evals()`. Pass a configured `LaravelAiJudge` or `LaravelAiEmbeddings` instance to select the provider and model in code:
290+
Alternatively, configure the drivers explicitly in your `tests/Pest.php` file using `pest()->evals()`. Pass a configured `LaravelAiJudge` or `LaravelAiEmbeddings` instance to select the provider and model in code:
290291

291292
```php
292293
use Pest\Evals\Drivers\LaravelAiEmbeddings;
@@ -297,12 +298,104 @@ pest()->evals()
297298
->embeddingsUsing(new LaravelAiEmbeddings(provider: 'openai', model: 'text-embedding-3-small'));
298299
```
299300

300-
If you would rather not use Laravel AI, or you want full control over how scores are produced, you may provide your own judge and embeddings drivers with a closure:
301+
### Bringing Your Own Driver: A Closure
302+
303+
The fastest way to leave Laravel AI behind is to hand `pest()->evals()` a closure. When you do this, `laravel/ai` is never touched, so it does not even need to be installed:
304+
305+
```php
306+
pest()->evals()
307+
->judgeUsing(function (string $instructions, string $prompt): string {
308+
// Call any model you like — an SDK, a raw HTTP client, anything —
309+
// and return its raw text response. The plugin parses the score out of it.
310+
return MyLlmClient::complete(system: $instructions, message: $prompt);
311+
})
312+
->embeddingsUsing(function (array $inputs): array {
313+
// Return one vector per input, in the same order they were given.
314+
return array_map(fn (string $text): array => MyLlmClient::embed($text), $inputs);
315+
});
316+
```
317+
318+
A judge driver is a plain text-in, text-out function. It does **not** need to know about scoring: the scorers build a prompt that already asks the model to reply with `{"score": <float>, "reasoning": "..."}`, and the plugin decodes that JSON for you. Your driver's only job is to forward the instructions and prompt to a model and return whatever text comes back.
319+
320+
An embeddings driver receives an array of strings and must return one numeric vector per string, in the same order.
321+
322+
### Bringing Your Own Driver: A Class
323+
324+
For anything you want to reuse or test, implement the contract as a dedicated class. Here a judge is backed by Anthropic:
325+
326+
```php
327+
use Pest\Evals\Contracts\JudgeDriver;
328+
329+
final class AnthropicJudge implements JudgeDriver
330+
{
331+
public function generate(string $instructions, string $prompt): string
332+
{
333+
// `$instructions` is the system prompt; `$prompt` asks for a JSON score.
334+
// Return the model's raw text — the plugin handles the parsing.
335+
return Anthropic::messages()->create(
336+
model: 'claude-sonnet-4-5',
337+
system: $instructions,
338+
messages: [['role' => 'user', 'content' => $prompt]],
339+
)->text();
340+
}
341+
}
342+
343+
pest()->evals()->judgeUsing(new AnthropicJudge());
344+
```
345+
346+
And an embeddings driver backed by a local model:
347+
348+
```php
349+
use Pest\Evals\Contracts\EmbeddingsDriver;
350+
351+
final class LocalEmbeddings implements EmbeddingsDriver
352+
{
353+
/**
354+
* @param array<int, string> $inputs
355+
* @return array<int, array<int, float>>
356+
*/
357+
public function embed(array $inputs): array
358+
{
359+
return array_map(
360+
fn (string $text): array => $this->model->encode($text),
361+
$inputs,
362+
);
363+
}
364+
}
365+
366+
pest()->evals()->embeddingsUsing(new LocalEmbeddings());
367+
```
368+
369+
### Returning a Fixed Result
370+
371+
A closure body is arbitrary code — usually it calls your client, but nothing stops it from returning a fixed value instead. Because a judge is just text-in / text-out and an embeddings driver is just array-in / array-out, you can hand back a canned result to exercise the full scoring path — and your custom scorers — without spending money or hitting the network. This is convenient in local development or CI smoke tests:
301372

302373
```php
303374
pest()->evals()
304-
->judgeUsing(fn (string $instructions, string $prompt): string => /* ... */)
305-
->embeddingsUsing(fn (array $inputs): array => /* ... */);
375+
->judgeUsing(fn (string $instructions, string $prompt): string =>
376+
'{"score": 1.0, "reasoning": "stubbed"}')
377+
->embeddingsUsing(fn (array $inputs): array =>
378+
array_map(fn (): array => [1.0, 0.0, 0.0], $inputs));
379+
```
380+
381+
---
382+
383+
<a name="running-evals"></a>
384+
## Running Evals
385+
386+
Because every eval calls a real model, evals are **skipped by default**. This keeps your everyday `pest` run fast, free, and deterministic — your evals live alongside your other tests without ever calling an API or slowing the suite down.
387+
388+
When you want to actually evaluate your agents, opt in with `--evals`:
389+
390+
```bash
391+
./vendor/bin/pest # evals are skipped
392+
./vendor/bin/pest --evals # evals run against the real model
393+
```
394+
395+
This applies to every target, including closures — an eval only runs under `--evals`. You may also force eval mode with the `PEST_EVALS` environment variable, which is convenient in CI:
396+
397+
```bash
398+
PEST_EVALS=1 ./vendor/bin/pest
306399
```
307400

308401
---

0 commit comments

Comments
 (0)