Skip to content
This repository was archived by the owner on Jul 16, 2025. It is now read-only.

fix: implement failing test and cleanup #308

Merged
merged 2 commits into from
May 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 1 addition & 6 deletions examples/openai/structured-output-clock.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,13 @@
use PhpLlm\LlmChain\Bridge\OpenAI\PlatformFactory;
use PhpLlm\LlmChain\Chain;
use PhpLlm\LlmChain\Chain\StructuredOutput\ChainProcessor as StructuredOutputProcessor;
use PhpLlm\LlmChain\Chain\StructuredOutput\ResponseFormatFactory;
use PhpLlm\LlmChain\Chain\Toolbox\ChainProcessor as ToolProcessor;
use PhpLlm\LlmChain\Chain\Toolbox\Tool\Clock;
use PhpLlm\LlmChain\Chain\Toolbox\Toolbox;
use PhpLlm\LlmChain\Model\Message\Message;
use PhpLlm\LlmChain\Model\Message\MessageBag;
use Symfony\Component\Clock\Clock as SymfonyClock;
use Symfony\Component\Dotenv\Dotenv;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;

require_once dirname(__DIR__, 2).'/vendor/autoload.php';
(new Dotenv())->loadEnv(dirname(__DIR__, 2).'/.env');
Expand All @@ -30,8 +26,7 @@
$clock = new Clock(new SymfonyClock());
$toolbox = Toolbox::create($clock);
$toolProcessor = new ToolProcessor($toolbox);
$serializer = new Serializer([new ObjectNormalizer()], [new JsonEncoder()]);
$structuredOutputProcessor = new StructuredOutputProcessor(new ResponseFormatFactory(), $serializer);
$structuredOutputProcessor = new StructuredOutputProcessor();
$chain = new Chain($platform, $llm, [$toolProcessor, $structuredOutputProcessor], [$toolProcessor, $structuredOutputProcessor]);

$messages = new MessageBag(Message::ofUser('What date and time is it?'));
Expand Down
7 changes: 1 addition & 6 deletions examples/openai/structured-output-math.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,10 @@
use PhpLlm\LlmChain\Bridge\OpenAI\PlatformFactory;
use PhpLlm\LlmChain\Chain;
use PhpLlm\LlmChain\Chain\StructuredOutput\ChainProcessor;
use PhpLlm\LlmChain\Chain\StructuredOutput\ResponseFormatFactory;
use PhpLlm\LlmChain\Model\Message\Message;
use PhpLlm\LlmChain\Model\Message\MessageBag;
use PhpLlm\LlmChain\Tests\Fixture\StructuredOutput\MathReasoning;
use Symfony\Component\Dotenv\Dotenv;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;

require_once dirname(__DIR__, 2).'/vendor/autoload.php';
(new Dotenv())->loadEnv(dirname(__DIR__, 2).'/.env');
Expand All @@ -23,9 +19,8 @@

$platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']);
$llm = new GPT(GPT::GPT_4O_MINI);
$serializer = new Serializer([new ObjectNormalizer()], [new JsonEncoder()]);

$processor = new ChainProcessor(new ResponseFormatFactory(), $serializer);
$processor = new ChainProcessor();
$chain = new Chain($platform, $llm, [$processor], [$processor]);
$messages = new MessageBag(
Message::forSystem('You are a helpful math tutor. Guide the user through the solution step by step.'),
Expand Down
15 changes: 13 additions & 2 deletions src/Chain/StructuredOutput/ChainProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,27 @@
use PhpLlm\LlmChain\Exception\InvalidArgumentException;
use PhpLlm\LlmChain\Exception\MissingModelSupport;
use PhpLlm\LlmChain\Model\Response\StructuredResponse;
use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor;
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Serializer\SerializerInterface;

final class ChainProcessor implements InputProcessor, OutputProcessor
{
private string $outputStructure;

public function __construct(
private readonly ResponseFormatFactoryInterface $responseFormatFactory,
private readonly SerializerInterface $serializer,
private readonly ResponseFormatFactoryInterface $responseFormatFactory = new ResponseFormatFactory(),
private ?SerializerInterface $serializer = null,
) {
if (null === $this->serializer) {
$propertyInfo = new PropertyInfoExtractor([], [new PhpDocExtractor()]);
$normalizers = [new ObjectNormalizer(propertyTypeExtractor: $propertyInfo), new ArrayDenormalizer()];
$this->serializer = $serializer ?? new Serializer($normalizers, [new JsonEncoder()]);
}
}

public function processInput(Input $input): void
Expand Down
76 changes: 61 additions & 15 deletions tests/Chain/StructuredOutput/ChainProcessorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,12 @@
use PhpLlm\LlmChain\Model\Response\TextResponse;
use PhpLlm\LlmChain\Tests\Double\ConfigurableResponseFormatFactory;
use PhpLlm\LlmChain\Tests\Fixture\SomeStructure;
use PhpLlm\LlmChain\Tests\Fixture\StructuredOutput\MathReasoning;
use PhpLlm\LlmChain\Tests\Fixture\StructuredOutput\Step;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\UsesClass;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Serializer\SerializerInterface;

#[CoversClass(ChainProcessor::class)]
Expand All @@ -37,9 +36,7 @@ final class ChainProcessorTest extends TestCase
#[Test]
public function processInputWithOutputStructure(): void
{
$responseFormatFactory = new ConfigurableResponseFormatFactory(['some' => 'format']);
$serializer = new Serializer([new ObjectNormalizer()], [new JsonEncoder()]);
$chainProcessor = new ChainProcessor($responseFormatFactory, $serializer);
$chainProcessor = new ChainProcessor(new ConfigurableResponseFormatFactory(['some' => 'format']));

$llm = self::createMock(LanguageModel::class);
$llm->method('supportsStructuredOutput')->willReturn(true);
Expand All @@ -54,9 +51,7 @@ public function processInputWithOutputStructure(): void
#[Test]
public function processInputWithoutOutputStructure(): void
{
$responseFormatFactory = new ConfigurableResponseFormatFactory();
$serializer = new Serializer([new ObjectNormalizer()], [new JsonEncoder()]);
$chainProcessor = new ChainProcessor($responseFormatFactory, $serializer);
$chainProcessor = new ChainProcessor(new ConfigurableResponseFormatFactory());

$llm = self::createMock(LanguageModel::class);
$input = new Input($llm, new MessageBag(), []);
Expand All @@ -71,9 +66,7 @@ public function processInputThrowsExceptionWhenLlmDoesNotSupportStructuredOutput
{
self::expectException(MissingModelSupport::class);

$responseFormatFactory = new ConfigurableResponseFormatFactory();
$serializer = new Serializer([new ObjectNormalizer()], [new JsonEncoder()]);
$chainProcessor = new ChainProcessor($responseFormatFactory, $serializer);
$chainProcessor = new ChainProcessor(new ConfigurableResponseFormatFactory());

$llm = self::createMock(LanguageModel::class);
$llm->method('supportsStructuredOutput')->willReturn(false);
Expand All @@ -86,9 +79,7 @@ public function processInputThrowsExceptionWhenLlmDoesNotSupportStructuredOutput
#[Test]
public function processOutputWithResponseFormat(): void
{
$responseFormatFactory = new ConfigurableResponseFormatFactory(['some' => 'format']);
$serializer = new Serializer([new ObjectNormalizer()], [new JsonEncoder()]);
$chainProcessor = new ChainProcessor($responseFormatFactory, $serializer);
$chainProcessor = new ChainProcessor(new ConfigurableResponseFormatFactory(['some' => 'format']));

$llm = self::createMock(LanguageModel::class);
$llm->method('supportsStructuredOutput')->willReturn(true);
Expand All @@ -108,6 +99,61 @@ public function processOutputWithResponseFormat(): void
self::assertSame('data', $output->response->getContent()->some);
}

#[Test]
public function processOutputWithComplexResponseFormat(): void
{
$chainProcessor = new ChainProcessor(new ConfigurableResponseFormatFactory(['some' => 'format']));

$llm = self::createMock(LanguageModel::class);
$llm->method('supportsStructuredOutput')->willReturn(true);

$options = ['output_structure' => MathReasoning::class];
$input = new Input($llm, new MessageBag(), $options);
$chainProcessor->processInput($input);

$response = new TextResponse(<<<JSON
{
"steps": [
{
"explanation": "We want to isolate the term with x. First, let's subtract 7 from both sides of the equation.",
"output": "8x + 7 - 7 = -23 - 7"
},
{
"explanation": "This simplifies to 8x = -30.",
"output": "8x = -30"
},
{
"explanation": "Next, to solve for x, we need to divide both sides of the equation by 8.",
"output": "x = -30 / 8"
},
{
"explanation": "Now we simplify -30 / 8 to its simplest form.",
"output": "x = -15 / 4"
},
{
"explanation": "Dividing both the numerator and the denominator by their greatest common divisor, we finalize our solution.",
"output": "x = -3.75"
}
],
"finalAnswer": "x = -3.75"
}
JSON);

$output = new Output($llm, $response, new MessageBag(), $input->getOptions());

$chainProcessor->processOutput($output);

self::assertInstanceOf(StructuredResponse::class, $output->response);
self::assertInstanceOf(MathReasoning::class, $structure = $output->response->getContent());
self::assertCount(5, $structure->steps);
self::assertInstanceOf(Step::class, $structure->steps[0]);
self::assertInstanceOf(Step::class, $structure->steps[1]);
self::assertInstanceOf(Step::class, $structure->steps[2]);
self::assertInstanceOf(Step::class, $structure->steps[3]);
self::assertInstanceOf(Step::class, $structure->steps[4]);
self::assertSame('x = -3.75', $structure->finalAnswer);
}

#[Test]
public function processOutputWithoutResponseFormat(): void
{
Expand Down