Skip to content

Structured output validation loop

Overview

This example validates the structured-output validation-loop workflow described in the InstructorPHP article. It uses InstructorPHP only.

It runs a deterministic invalid-then-corrected response through the real StructuredOutput pipeline, captures validation and retry events, and then runs the same response model against OpenAI when an API key is available.

Example

<?php
require 'examples/boot.php';

use Cognesy\Events\Dispatchers\EventDispatcher;
use Cognesy\Instructor\Enums\OutputMode;
use Cognesy\Instructor\Events\Attempt\ResponseRetryScheduled;
use Cognesy\Instructor\Events\Response\ResponseValidated;
use Cognesy\Instructor\Events\Response\ResponseValidationFailed;
use Cognesy\Instructor\Events\StructuredOutput\StructuredOutputResponseGenerated;
use Cognesy\Instructor\StructuredOutput;
use Cognesy\Instructor\StructuredOutputRuntime;
use Cognesy\Polyglot\Inference\Contracts\CanProcessInferenceRequest;
use Cognesy\Polyglot\Inference\Data\InferenceRequest;
use Cognesy\Polyglot\Inference\Data\InferenceResponse;
use Cognesy\Polyglot\Inference\Data\PartialInferenceDelta;
use Cognesy\Polyglot\Inference\LLMProvider;
use Symfony\Component\Validator\Constraints as Assert;

enum ArticleIncidentSeverity: string {
    case Low = 'low';
    case Medium = 'medium';
    case High = 'high';
    case Critical = 'critical';
}

final class ArticleValidatedIncident
{
    public function __construct(
        #[Assert\NotBlank]
        public string $service,

        #[Assert\Choice(choices: ['low', 'medium', 'high', 'critical'])]
        public string $severity,

        #[Assert\NotBlank]
        public string $owner,

        #[Assert\Choice(choices: ['open', 'triaged', 'closed'])]
        public string $status,

        #[Assert\DateTime(format: DateTimeInterface::ATOM)]
        public string $reportedAt,
    ) {}
}

final class ArticleEnumIncident
{
    public function __construct(
        public string $service,
        public ArticleIncidentSeverity $severity,
        public string $owner,
        public string $status,
        public string $reportedAt,
    ) {}
}

final class ArticleRecordingDriver implements CanProcessInferenceRequest
{
    /** @var list<InferenceResponse> */
    private array $responses;

    /** @var list<array<int, array<string, mixed>>> */
    public array $requests = [];

    /** @param list<array<string, mixed>> $responses */
    public function __construct(array $responses)
    {
        $this->responses = array_map(
            static fn(array $response): InferenceResponse => new InferenceResponse(
                content: json_encode($response, JSON_THROW_ON_ERROR),
                finishReason: 'stop',
            ),
            $responses,
        );
    }

    public function makeResponseFor(InferenceRequest $request): InferenceResponse
    {
        $this->requests[] = $request->messages()->toArray();

        if ($this->responses === []) {
            return new InferenceResponse(content: '{}', finishReason: 'stop');
        }

        return array_shift($this->responses);
    }

    /** @return iterable<PartialInferenceDelta> */
    public function makeStreamDeltasFor(InferenceRequest $request): iterable
    {
        return [];
    }
}

function articleIncidentInput(): string
{
    return <<<TEXT
Subject: Payment failed again / maybe already handled?

Notes copied from Slack and Zendesk:
- "around breakfast UTC" Priya said Billing API was throwing 502s.
- Three checkout attempts failed before somebody wrote "finance ops owns this unless web owns it".
- Alex later marked the support ticket "looked at", not closed.
- Customer phrasing: "urgent, live orders blocked, please do not call it critical unless that is the only valid high-severity value".
- The ops note says use status "needs attention" in the ticketing tool, but our incident workflow only accepts open, triaged, or closed.
- Reference time for the report: 2026-07-11 09:10 UTC.
TEXT;
}

/** @return array{driver: ArticleRecordingDriver, events: list<object>, incident: ArticleValidatedIncident} */
function runDeterministicValidationLoop(): array
{
    $driver = new ArticleRecordingDriver([
        [
            'service' => 'Billing API',
            'severity' => 'urgent',
            'owner' => 'finance ops',
            'status' => 'needs attention',
            'reportedAt' => 'this morning',
        ],
        [
            'service' => 'Billing API',
            'severity' => 'critical',
            'owner' => 'finance ops',
            'status' => 'open',
            'reportedAt' => '2026-07-11T09:10:00+00:00',
        ],
    ]);

    $events = [];
    $dispatcher = new EventDispatcher('article-validation-loop');
    $dispatcher->wiretap(static function(object $event) use (&$events): void {
        $events[] = $event;
    });

    $runtime = StructuredOutputRuntime::fromProvider(
        provider: LLMProvider::new()->withDriver($driver),
        events: $dispatcher,
    )
        ->withOutputMode(OutputMode::Json)
        ->withMaxRetries(2);

    $incident = (new StructuredOutput($runtime))
        ->with(
            messages: articleIncidentInput(),
            responseModel: ArticleValidatedIncident::class,
            model: 'gpt-4o-mini',
        )
        ->get();

    if (!$incident instanceof ArticleValidatedIncident) {
        throw new RuntimeException('Expected ArticleValidatedIncident from deterministic validation loop.');
    }

    return ['driver' => $driver, 'events' => $events, 'incident' => $incident];
}

/** @return array{events: list<object>, incident: ArticleValidatedIncident, model: string}|null */
function runLiveOpenAiValidationLoop(): ?array
{
    if (getenv('OPENAI_API_KEY') === false || getenv('INSTRUCTOR_EXAMPLES_SKIP_LIVE') === '1') {
        return null;
    }

    $events = [];
    $dispatcher = new EventDispatcher('article-validation-loop-live');
    $dispatcher->wiretap(static function(object $event) use (&$events): void {
        $events[] = $event;
    });

    $model = getenv('INSTRUCTOR_EXAMPLES_LIVE_MODEL') ?: 'gpt-4.1-nano';

    $runtime = StructuredOutputRuntime::fromProvider(
        provider: LLMProvider::using('openai')->withModel($model),
        events: $dispatcher,
    )
        ->withOutputMode(OutputMode::Json)
        ->withMaxRetries(2);

    $incident = (new StructuredOutput($runtime))
        ->with(
            messages: articleIncidentInput(),
            responseModel: ArticleValidatedIncident::class,
            prompt: 'Extract one incident from the messy notes. Preserve the source wording where it appears operationally meaningful.',
        )
        ->get();

    if (!$incident instanceof ArticleValidatedIncident) {
        throw new RuntimeException('Expected ArticleValidatedIncident from live validation loop.');
    }

    return ['events' => $events, 'incident' => $incident, 'model' => $model];
}

function eventCount(array $events, string $class): int
{
    return count(array_filter(
        $events,
        static fn(object $event): bool => $event instanceof $class,
    ));
}

function containsInMessages(array $requests, string $needle): bool
{
    foreach ($requests as $messages) {
        $encoded = json_encode($messages, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
        if (is_string($encoded) && str_contains($encoded, $needle)) {
            return true;
        }
    }
    return false;
}

function printEventSummary(string $label, array $events): void
{
    echo "{$label} events:\n";
    echo '  ResponseValidationFailed: ' . eventCount($events, ResponseValidationFailed::class) . "\n";
    echo '  ResponseRetryScheduled: ' . eventCount($events, ResponseRetryScheduled::class) . "\n";
    echo '  ResponseValidated: ' . eventCount($events, ResponseValidated::class) . "\n";
    echo '  StructuredOutputResponseGenerated: ' . eventCount($events, StructuredOutputResponseGenerated::class) . "\n";
}

$deterministic = runDeterministicValidationLoop();
$incident = $deterministic['incident'];
$events = $deterministic['events'];
$driver = $deterministic['driver'];

echo "=== Deterministic validation loop ===\n";
echo "Accepted incident:\n";
echo json_encode(get_object_vars($incident), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n\n";
printEventSummary('Deterministic', $events);
echo '  Requests sent to driver: ' . count($driver->requests) . "\n";
echo '  Retry prompt included invalid severity: ' . (containsInMessages($driver->requests, 'urgent') ? 'yes' : 'no') . "\n";
echo '  Retry prompt included validation error: ' . (containsInMessages($driver->requests, 'The value you selected is not a valid choice') ? 'yes' : 'no') . "\n\n";

if ($incident->severity !== 'critical') {
    throw new RuntimeException('Expected repaired severity to be critical.');
}
if ($incident->status !== 'open') {
    throw new RuntimeException('Expected repaired status to be open.');
}
if (eventCount($events, ResponseValidationFailed::class) < 1) {
    throw new RuntimeException('Expected validation failure event.');
}
if (eventCount($events, ResponseRetryScheduled::class) < 1) {
    throw new RuntimeException('Expected validation recovery event.');
}
if (count($driver->requests) !== 2) {
    throw new RuntimeException('Expected exactly two deterministic LLM requests.');
}

echo "=== Enum edge probe ===\n";
echo "The article uses a PHP enum for severity. This probe checks whether an invalid enum value is reported as validation feedback or as deserialization repair/failure.\n";

$enumDriver = new ArticleRecordingDriver([
    [
        'service' => 'Billing API',
        'severity' => 'urgent',
        'owner' => 'finance ops',
        'status' => 'open',
        'reportedAt' => '2026-07-11T09:10:00+00:00',
    ],
    [
        'service' => 'Billing API',
        'severity' => 'critical',
        'owner' => 'finance ops',
        'status' => 'open',
        'reportedAt' => '2026-07-11T09:10:00+00:00',
    ],
]);
$enumEvents = [];
$enumDispatcher = new EventDispatcher('article-validation-loop-enum');
$enumDispatcher->wiretap(static function(object $event) use (&$enumEvents): void {
    $enumEvents[] = $event;
});
$enumRuntime = StructuredOutputRuntime::fromProvider(
    provider: LLMProvider::new()->withDriver($enumDriver),
    events: $enumDispatcher,
)
    ->withOutputMode(OutputMode::Json)
    ->withMaxRetries(1);

$enumIncident = (new StructuredOutput($enumRuntime))
    ->with(
        messages: articleIncidentInput(),
        responseModel: ArticleEnumIncident::class,
        model: 'gpt-4o-mini',
    )
    ->get();

if (!$enumIncident instanceof ArticleEnumIncident) {
    throw new RuntimeException('Expected ArticleEnumIncident after enum repair.');
}

echo "Enum probe repaired severity: {$enumIncident->severity->value}\n";
printEventSummary('Enum probe', $enumEvents);
echo '  Requests sent to driver: ' . count($enumDriver->requests) . "\n\n";

$live = runLiveOpenAiValidationLoop();
if ($live === null) {
    echo "=== Live OpenAI validation loop ===\n";
    echo "Skipped. Set OPENAI_API_KEY and leave INSTRUCTOR_EXAMPLES_SKIP_LIVE unset to run it.\n";
    return;
}

echo "=== Live OpenAI validation loop ===\n";
echo "Model: {$live['model']}\n";
echo "Accepted incident:\n";
echo json_encode(get_object_vars($live['incident']), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n\n";
printEventSummary('Live OpenAI', $live['events']);

if ($live['incident']->service === '' || $live['incident']->owner === '') {
    throw new RuntimeException('Expected live incident to contain service and owner.');
}
?>