diff --git a/docs/components/platform.rst b/docs/components/platform.rst index fda6b0f4b7..749ae51b94 100644 --- a/docs/components/platform.rst +++ b/docs/components/platform.rst @@ -489,11 +489,68 @@ top this example uses the feature through the agent to leverage tool calling:: dump($result->getContent()); // returns an array +Populating Existing Objects +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Instead of creating new object instances, you can pass existing object instances to have the AI populate missing data. +This is useful when you have partially filled objects and want the AI to complete them. + +You can provide context to the AI in two ways: either describe the task in plain text, or pass the object +directly to the message for automatic serialization:: + + use Symfony\AI\Platform\Message\Message; + use Symfony\AI\Platform\Message\MessageBag; + + // Create a partially populated object + class City + { + public function __construct( + public ?string $name = null, + public ?int $population = null, + public ?string $country = null, + public ?string $mayor = null, + ) {} + } + + $city = new City(name: 'Berlin'); + + // You can either describe the task in plain text or pass the object directly + $messages = new MessageBag( + Message::forSystem('You are a helpful assistant that provides information about cities.'), + // describe the task + Message::ofUser('Please provide the population, country, and current mayor for Berlin.'), + // or directly hand over the given data + Message::ofUser('Research missing data for this city', $city), + ); + + $result = $platform->invoke('gpt-5-mini', $messages, [ + 'response_format' => $city, // Pass the instance instead of the class + ]); + + // The same object instance is returned with populated fields + $populatedCity = $result->asObject(); + assert($city === $populatedCity); // Same object! + + echo $city->population; // 3500000 + echo $city->country; // Germany + echo $city->mayor; // Kai Wegner + +When you pass an object to ``Message::ofUser()`` as the last parameter, it is automatically JSON-serialized +and included in the message content. This provides the AI with the current state of the object, making it +clear what data is already present and what needs to be filled. + +The AI will populate the missing fields while preserving any existing values. This is particularly useful for: + +- Enriching partial data from databases +- Updating incomplete records +- Progressive data collection workflows + Code Examples ~~~~~~~~~~~~~ * `Structured Output with PHP class`_ * `Structured Output with array`_ +* `Populating existing objects`_ Server Tools ------------ @@ -748,6 +805,7 @@ Code Examples .. _`Embeddings with Mistral`: https://github.com/symfony/ai/blob/main/examples/mistral/embeddings.php .. _`Structured Output with PHP class`: https://github.com/symfony/ai/blob/main/examples/openai/structured-output-math.php .. _`Structured Output with array`: https://github.com/symfony/ai/blob/main/examples/openai/structured-output-clock.php +.. _`Populating existing objects`: https://github.com/symfony/ai/blob/main/examples/platform/structured-output-populate-object.php .. _`Parallel GPT Calls`: https://github.com/symfony/ai/blob/main/examples/misc/parallel-chat-gpt.php .. _`Parallel Embeddings Calls`: https://github.com/symfony/ai/blob/main/examples/misc/parallel-embeddings.php .. _`LM Studio`: https://lmstudio.ai/ diff --git a/examples/platform/structured-output-populate-object.php b/examples/platform/structured-output-populate-object.php new file mode 100644 index 0000000000..cf1fac3265 --- /dev/null +++ b/examples/platform/structured-output-populate-object.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\StructuredOutput\PlatformSubscriber; +use Symfony\AI\Platform\Tests\Fixtures\StructuredOutput\City; +use Symfony\Component\EventDispatcher\EventDispatcher; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$dispatcher = new EventDispatcher(); +$dispatcher->addSubscriber(new PlatformSubscriber()); + +$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client(), eventDispatcher: $dispatcher); + +// Create a partially populated object with only the city name +$city = new City(name: 'Berlin'); + +echo "Initial object state:\n"; +dump($city); + +$messages = new MessageBag( + Message::forSystem('You are a helpful assistant that provides information about cities.'), + Message::ofUser('Please research the missing data attributes for this city', $city), +); + +$result = $platform->invoke('gpt-5-mini', $messages, [ + 'response_format' => $city, +]); + +echo "\nPopulated object state:\n"; +dump($result->asObject()); + +// Verify that the same object instance was populated +echo "\nObject identity preserved: "; +dump($city === $result->asObject()); diff --git a/src/platform/CHANGELOG.md b/src/platform/CHANGELOG.md index e73fc9d11f..53a87c72ab 100644 --- a/src/platform/CHANGELOG.md +++ b/src/platform/CHANGELOG.md @@ -4,6 +4,8 @@ CHANGELOG 0.3 --- + * Add support for populating existing object instances in structured output via `response_format` option + * Add support for passing objects to `Message::ofUser()` for automatic serialization as context * Add `StreamListenerInterface` to hook into response streams * [BC BREAK] Change `TokenUsageAggregation::__construct()` from variadic to array * Add `TokenUsageAggregation::add()` method to add more token usages diff --git a/src/platform/src/Message/Message.php b/src/platform/src/Message/Message.php index fc3db2fd8d..be638422f8 100644 --- a/src/platform/src/Message/Message.php +++ b/src/platform/src/Message/Message.php @@ -11,6 +11,7 @@ namespace Symfony\AI\Platform\Message; +use Symfony\AI\Platform\Exception\InvalidArgumentException; use Symfony\AI\Platform\Message\Content\ContentInterface; use Symfony\AI\Platform\Message\Content\Text; use Symfony\AI\Platform\Result\ToolCall; @@ -43,8 +44,22 @@ public static function ofAssistant(?string $content = null, ?array $toolCalls = return new AssistantMessage($content, $toolCalls); } - public static function ofUser(\Stringable|string|ContentInterface ...$content): UserMessage + public static function ofUser(mixed ...$content): UserMessage { + $contextObject = null; + if (\count($content) > 0) { + $lastArg = $content[\count($content) - 1]; + if (\is_object($lastArg) && !$lastArg instanceof \Stringable && !$lastArg instanceof ContentInterface) { + $contextObject = array_pop($content); + } + } + + foreach ($content as $entry) { + if (!\is_string($entry) && !$entry instanceof \Stringable && !$entry instanceof ContentInterface) { + throw new InvalidArgumentException(\sprintf('Content must be string, Stringable, or ContentInterface, "%s" given.', get_debug_type($entry))); + } + } + $content = array_map( static fn (\Stringable|string|ContentInterface $entry) => match (true) { $entry instanceof ContentInterface => $entry, @@ -54,7 +69,17 @@ public static function ofUser(\Stringable|string|ContentInterface ...$content): $content, ); - return new UserMessage(...$content); + if (null !== $contextObject) { + $content[] = new Text(json_encode($contextObject, \JSON_PRETTY_PRINT | \JSON_THROW_ON_ERROR)); + } + + $message = new UserMessage(...$content); + + if (null !== $contextObject) { + $message->getMetadata()->add('structured_output_object', $contextObject); + } + + return $message; } public static function ofToolCall(ToolCall $toolCall, string $content): ToolCallMessage diff --git a/src/platform/src/StructuredOutput/PlatformSubscriber.php b/src/platform/src/StructuredOutput/PlatformSubscriber.php index ab91f06323..90e252e942 100644 --- a/src/platform/src/StructuredOutput/PlatformSubscriber.php +++ b/src/platform/src/StructuredOutput/PlatformSubscriber.php @@ -29,6 +29,8 @@ final class PlatformSubscriber implements EventSubscriberInterface private string $outputType; + private ?object $objectToPopulate = null; + private SerializerInterface $serializer; public function __construct( @@ -54,11 +56,19 @@ public function processInput(InvocationEvent $event): void { $options = $event->getOptions(); - if (!isset($options[self::RESPONSE_FORMAT]) || !\is_string($options[self::RESPONSE_FORMAT])) { + if (!isset($options[self::RESPONSE_FORMAT])) { return; } - if (!class_exists($options[self::RESPONSE_FORMAT])) { + $responseFormat = $options[self::RESPONSE_FORMAT]; + + if (\is_object($responseFormat)) { + $this->objectToPopulate = $responseFormat; + $className = $responseFormat::class; + } elseif (\is_string($responseFormat) && class_exists($responseFormat)) { + $this->objectToPopulate = null; + $className = $responseFormat; + } else { return; } @@ -70,9 +80,9 @@ public function processInput(InvocationEvent $event): void throw MissingModelSupportException::forStructuredOutput($event->getModel()); } - $this->outputType = $options[self::RESPONSE_FORMAT]; + $this->outputType = $className; - $options[self::RESPONSE_FORMAT] = $this->responseFormatFactory->create($options[self::RESPONSE_FORMAT]); + $options[self::RESPONSE_FORMAT] = $this->responseFormatFactory->create($className); $event->setOptions($options); } @@ -86,8 +96,16 @@ public function processResult(ResultEvent $event): void } $deferred = $event->getDeferredResult(); - $converter = new ResultConverter($deferred->getResultConverter(), $this->serializer, $this->outputType ?? null); + $converter = new ResultConverter( + $deferred->getResultConverter(), + $this->serializer, + $this->outputType ?? null, + $this->objectToPopulate + ); $event->setDeferredResult(new DeferredResult($converter, $deferred->getRawResult(), $options)); + + // Reset object to populate for next invocation + $this->objectToPopulate = null; } } diff --git a/src/platform/src/StructuredOutput/ResultConverter.php b/src/platform/src/StructuredOutput/ResultConverter.php index c9fd0e82ea..edf6312673 100644 --- a/src/platform/src/StructuredOutput/ResultConverter.php +++ b/src/platform/src/StructuredOutput/ResultConverter.php @@ -20,6 +20,7 @@ use Symfony\AI\Platform\ResultConverterInterface; use Symfony\AI\Platform\TokenUsage\TokenUsageExtractorInterface; use Symfony\Component\Serializer\Exception\ExceptionInterface as SerializerExceptionInterface; +use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Serializer\SerializerInterface; final class ResultConverter implements ResultConverterInterface @@ -28,6 +29,7 @@ public function __construct( private readonly ResultConverterInterface $innerConverter, private readonly SerializerInterface $serializer, private readonly ?string $outputType = null, + private readonly ?object $objectToPopulate = null, ) { } @@ -45,8 +47,19 @@ public function convert(RawResultInterface $result, array $options = []): Result } try { - $structure = null === $this->outputType ? json_decode($innerResult->getContent(), true, flags: \JSON_THROW_ON_ERROR) - : $this->serializer->deserialize($innerResult->getContent(), $this->outputType, 'json'); + $context = []; + if (null !== $this->objectToPopulate) { + $context[AbstractNormalizer::OBJECT_TO_POPULATE] = $this->objectToPopulate; + } + + $structure = null === $this->outputType + ? json_decode($innerResult->getContent(), true, flags: \JSON_THROW_ON_ERROR) + : $this->serializer->deserialize( + $innerResult->getContent(), + $this->outputType, + 'json', + $context + ); } catch (\JsonException $e) { throw new RuntimeException('Cannot json decode the content.', previous: $e); } catch (SerializerExceptionInterface $e) { diff --git a/src/platform/tests/Fixtures/StructuredOutput/City.php b/src/platform/tests/Fixtures/StructuredOutput/City.php new file mode 100644 index 0000000000..f2976287c7 --- /dev/null +++ b/src/platform/tests/Fixtures/StructuredOutput/City.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Fixtures\StructuredOutput; + +final class City +{ + public function __construct( + public ?string $name = null, + public ?int $population = null, + public ?string $country = null, + public ?string $mayor = null, + ) { + } +} diff --git a/src/platform/tests/Message/MessageTest.php b/src/platform/tests/Message/MessageTest.php index b31a6b2380..b25fe1048c 100644 --- a/src/platform/tests/Message/MessageTest.php +++ b/src/platform/tests/Message/MessageTest.php @@ -17,6 +17,7 @@ use Symfony\AI\Platform\Message\Content\Text; use Symfony\AI\Platform\Message\Message; use Symfony\AI\Platform\Result\ToolCall; +use Symfony\AI\Platform\Tests\Fixtures\StructuredOutput\City; final class MessageTest extends TestCase { @@ -122,4 +123,117 @@ public function testCreateToolCallMessage() $this->assertSame('Foo bar.', $message->getContent()); $this->assertSame($toolCall, $message->getToolCall()); } + + public function testCreateUserMessageWithObjectSerializesItAsContext() + { + $city = new City(name: 'Berlin', population: 3500000); + $message = Message::ofUser('Please research missing data', $city); + + $content = $message->getContent(); + $this->assertCount(2, $content); + + $this->assertInstanceOf(Text::class, $content[0]); + $this->assertSame('Please research missing data', $content[0]->getText()); + + $this->assertInstanceOf(Text::class, $content[1]); + $serializedContent = $content[1]->getText(); + $this->assertStringContainsString('Berlin', $serializedContent); + $this->assertStringContainsString('3500000', $serializedContent); + $this->assertStringContainsString('"name"', $serializedContent); + $this->assertStringContainsString('"population"', $serializedContent); + } + + public function testCreateUserMessageWithObjectStoresItInMetadata() + { + $city = new City(name: 'Berlin'); + $message = Message::ofUser('Research data', $city); + + $this->assertTrue($message->getMetadata()->has('structured_output_object')); + $this->assertSame($city, $message->getMetadata()->get('structured_output_object')); + } + + public function testCreateUserMessageWithMultipleContentAndObject() + { + $city = new City(name: 'Paris'); + $message = Message::ofUser( + 'Check this city', + new ImageUrl('http://example.com/paris.jpg'), + $city + ); + + $content = $message->getContent(); + $this->assertCount(3, $content); + + $this->assertInstanceOf(Text::class, $content[0]); + $this->assertInstanceOf(ImageUrl::class, $content[1]); + + $this->assertInstanceOf(Text::class, $content[2]); + $this->assertStringContainsString('Paris', $content[2]->getText()); + } + + public function testCreateUserMessageWithOnlyObject() + { + $city = new City(name: 'Tokyo'); + $message = Message::ofUser($city); + + $content = $message->getContent(); + $this->assertCount(1, $content); + + $this->assertInstanceOf(Text::class, $content[0]); + $serializedContent = $content[0]->getText(); + $this->assertStringContainsString('Tokyo', $serializedContent); + $this->assertStringContainsString('"name"', $serializedContent); + } + + public function testCreateUserMessageWithStringableIsNotTreatedAsObject() + { + $stringable = new class implements \Stringable { + public function __toString(): string + { + return 'I am stringable'; + } + }; + + $message = Message::ofUser('Hello', $stringable); + + $content = $message->getContent(); + $this->assertCount(2, $content); + + $this->assertInstanceOf(Text::class, $content[0]); + $this->assertInstanceOf(Text::class, $content[1]); + $this->assertSame('I am stringable', $content[1]->getText()); + + $this->assertFalse($message->getMetadata()->has('structured_output_object')); + } + + public function testCreateUserMessageWithContentInterfaceIsNotTreatedAsObject() + { + $imageUrl = new ImageUrl('http://example.com/image.jpg'); + $message = Message::ofUser('Look at this', $imageUrl); + + $content = $message->getContent(); + $this->assertCount(2, $content); + + $this->assertInstanceOf(Text::class, $content[0]); + $this->assertInstanceOf(ImageUrl::class, $content[1]); + + $this->assertFalse($message->getMetadata()->has('structured_output_object')); + } + + public function testCreateUserMessageThrowsExceptionForInvalidContentType() + { + $this->expectException(\Symfony\AI\Platform\Exception\InvalidArgumentException::class); + $this->expectExceptionMessage('Content must be string, Stringable, or ContentInterface'); + + Message::ofUser('Hello', ['invalid' => 'array']); + } + + public function testCreateUserMessageWithObjectAndInvalidTypeThrowsException() + { + $this->expectException(\Symfony\AI\Platform\Exception\InvalidArgumentException::class); + $this->expectExceptionMessage('Content must be string, Stringable, or ContentInterface'); + + $city = new City(name: 'London'); + Message::ofUser(123, 'text', $city); + } } diff --git a/src/platform/tests/StructuredOutput/PlatformSubscriberTest.php b/src/platform/tests/StructuredOutput/PlatformSubscriberTest.php index 8e477aea19..b3aec507a0 100644 --- a/src/platform/tests/StructuredOutput/PlatformSubscriberTest.php +++ b/src/platform/tests/StructuredOutput/PlatformSubscriberTest.php @@ -18,6 +18,8 @@ use Symfony\AI\Platform\Event\InvocationEvent; use Symfony\AI\Platform\Event\ResultEvent; use Symfony\AI\Platform\Exception\MissingModelSupportException; +use Symfony\AI\Platform\Message\Content\Text; +use Symfony\AI\Platform\Message\Message; use Symfony\AI\Platform\Message\MessageBag; use Symfony\AI\Platform\Metadata\Metadata; use Symfony\AI\Platform\Model; @@ -27,6 +29,7 @@ use Symfony\AI\Platform\Result\ObjectResult; use Symfony\AI\Platform\Result\TextResult; use Symfony\AI\Platform\StructuredOutput\PlatformSubscriber; +use Symfony\AI\Platform\Tests\Fixtures\StructuredOutput\City; use Symfony\AI\Platform\Tests\Fixtures\StructuredOutput\MathReasoning; use Symfony\AI\Platform\Tests\Fixtures\StructuredOutput\MathReasoningWithAttributes; use Symfony\AI\Platform\Tests\Fixtures\StructuredOutput\PolymorphicType\ListItemAge; @@ -282,4 +285,197 @@ public function testProcessOutputWithoutResponseFormat() $this->assertSame($result, $event->getDeferredResult()->getResult()); } + + public function testProcessInputWithObjectInstance() + { + $processor = new PlatformSubscriber(new ConfigurableResponseFormatFactory(['some' => 'format'])); + $city = new City(name: 'Berlin'); + $event = new InvocationEvent(new Model('gpt-4', [Capability::OUTPUT_STRUCTURED]), new MessageBag(), [ + 'response_format' => $city, + ]); + + $processor->processInput($event); + + $this->assertSame(['response_format' => ['some' => 'format']], $event->getOptions()); + } + + public function testProcessOutputWithObjectInstance() + { + $processor = new PlatformSubscriber(new ConfigurableResponseFormatFactory(['some' => 'format'])); + + $city = new City(name: 'Berlin'); + $model = new Model('gpt-4', [Capability::OUTPUT_STRUCTURED]); + $invocationEvent = new InvocationEvent($model, new MessageBag(), ['response_format' => $city]); + $processor->processInput($invocationEvent); + + $converter = new PlainConverter(new TextResult('{"name": "Berlin", "population": 3500000, "country": "Germany", "mayor": "Kai Wegner"}')); + $deferred = new DeferredResult($converter, new InMemoryRawResult()); + $resultEvent = new ResultEvent($model, $deferred, $invocationEvent->getOptions()); + + $processor->processResult($resultEvent); + + $deferredResult = $resultEvent->getDeferredResult(); + $this->assertInstanceOf(ObjectResult::class, $deferredResult->getResult()); + $result = $deferredResult->asObject(); + $this->assertInstanceOf(City::class, $result); + $this->assertSame($city, $result); + $this->assertSame('Berlin', $result->name); + $this->assertSame(3500000, $result->population); + $this->assertSame('Germany', $result->country); + $this->assertSame('Kai Wegner', $result->mayor); + } + + public function testObjectInstancePreservesExistingValues() + { + $processor = new PlatformSubscriber(new ConfigurableResponseFormatFactory(['some' => 'format'])); + + $city = new City(name: 'Berlin', country: 'Germany'); + $model = new Model('gpt-4', [Capability::OUTPUT_STRUCTURED]); + $invocationEvent = new InvocationEvent($model, new MessageBag(), ['response_format' => $city]); + $processor->processInput($invocationEvent); + + $converter = new PlainConverter(new TextResult('{"population": 3500000, "mayor": "Kai Wegner"}')); + $deferred = new DeferredResult($converter, new InMemoryRawResult()); + $resultEvent = new ResultEvent($model, $deferred, $invocationEvent->getOptions()); + + $processor->processResult($resultEvent); + + $result = $resultEvent->getDeferredResult()->asObject(); + $this->assertSame($city, $result); + $this->assertSame('Berlin', $result->name); + $this->assertSame('Germany', $result->country); + $this->assertSame(3500000, $result->population); + $this->assertSame('Kai Wegner', $result->mayor); + } + + public function testObjectInstanceThrowsExceptionWithStreaming() + { + $this->expectException(\Symfony\AI\Platform\Exception\InvalidArgumentException::class); + $this->expectExceptionMessage('Streamed responses are not supported for structured output.'); + + $processor = new PlatformSubscriber(new ConfigurableResponseFormatFactory()); + + $city = new City(name: 'Berlin'); + $model = new Model('gpt-4', [Capability::OUTPUT_STRUCTURED]); + $event = new InvocationEvent($model, new MessageBag(), [ + 'response_format' => $city, + 'stream' => true, + ]); + + $processor->processInput($event); + } + + public function testObjectPassedToUserMessageIsSerializedAsContext() + { + $city = new City(name: 'Berlin'); + $message = Message::ofUser('Please research missing data', $city); + + $content = $message->getContent(); + $this->assertCount(2, $content); + + $this->assertInstanceOf(Text::class, $content[0]); + $this->assertSame('Please research missing data', $content[0]->getText()); + + $this->assertInstanceOf(Text::class, $content[1]); + $serializedContent = $content[1]->getText(); + $this->assertStringContainsString('Berlin', $serializedContent); + $this->assertStringContainsString('"name"', $serializedContent); + } + + public function testProcessInputIgnoresNonObjectNonClassString() + { + $processor = new PlatformSubscriber(new ConfigurableResponseFormatFactory()); + + $model = new Model('gpt-4', [Capability::OUTPUT_STRUCTURED]); + $event = new InvocationEvent($model, new MessageBag(), [ + 'response_format' => 'invalid-class-name', + ]); + + $processor->processInput($event); + + $this->assertSame(['response_format' => 'invalid-class-name'], $event->getOptions()); + } + + public function testProcessInputIgnoresNonStructuredOutputResponseFormats() + { + $processor = new PlatformSubscriber(new ConfigurableResponseFormatFactory()); + + $model = new Model('dalle-2'); + $event = new InvocationEvent($model, new MessageBag(), [ + 'response_format' => 'url', + ]); + + $processor->processInput($event); + + $this->assertSame(['response_format' => 'url'], $event->getOptions()); + } + + public function testObjectToPopulateIsResetAfterProcessResult() + { + $processor = new PlatformSubscriber(new ConfigurableResponseFormatFactory(['some' => 'format'])); + + $city1 = new City(name: 'Berlin'); + $model = new Model('gpt-4', [Capability::OUTPUT_STRUCTURED]); + $invocationEvent1 = new InvocationEvent($model, new MessageBag(), ['response_format' => $city1]); + $processor->processInput($invocationEvent1); + + $converter = new PlainConverter(new TextResult('{"name": "Berlin", "population": 3500000}')); + $deferred = new DeferredResult($converter, new InMemoryRawResult()); + $resultEvent1 = new ResultEvent($model, $deferred, $invocationEvent1->getOptions()); + $processor->processResult($resultEvent1); + + $invocationEvent2 = new InvocationEvent($model, new MessageBag(), ['response_format' => City::class]); + $processor->processInput($invocationEvent2); + + $converter2 = new PlainConverter(new TextResult('{"name": "Paris", "population": 2161000}')); + $deferred2 = new DeferredResult($converter2, new InMemoryRawResult()); + $resultEvent2 = new ResultEvent($model, $deferred2, $invocationEvent2->getOptions()); + $processor->processResult($resultEvent2); + + $result2 = $resultEvent2->getDeferredResult()->asObject(); + + $this->assertInstanceOf(City::class, $result2); + $this->assertNotSame($city1, $result2); + $this->assertSame('Paris', $result2->name); + } + + public function testMultipleObjectInstancesInSequenceAreHandledCorrectly() + { + $processor = new PlatformSubscriber(new ConfigurableResponseFormatFactory(['some' => 'format'])); + + $city1 = new City(name: 'Berlin'); + $city2 = new City(name: 'Paris'); + + $model = new Model('gpt-4', [Capability::OUTPUT_STRUCTURED]); + + // First invocation + $invocationEvent1 = new InvocationEvent($model, new MessageBag(), ['response_format' => $city1]); + $processor->processInput($invocationEvent1); + $this->assertSame(['response_format' => ['some' => 'format']], $invocationEvent1->getOptions()); + + $converter1 = new PlainConverter(new TextResult('{"name": "Berlin", "population": 3500000}')); + $deferred1 = new DeferredResult($converter1, new InMemoryRawResult()); + $resultEvent1 = new ResultEvent($model, $deferred1, $invocationEvent1->getOptions()); + $processor->processResult($resultEvent1); + + $result1 = $resultEvent1->getDeferredResult()->asObject(); + $this->assertSame($city1, $result1); + + // Second invocation with different object + $invocationEvent2 = new InvocationEvent($model, new MessageBag(), ['response_format' => $city2]); + $processor->processInput($invocationEvent2); + + $converter2 = new PlainConverter(new TextResult('{"name": "Paris", "population": 2161000}')); + $deferred2 = new DeferredResult($converter2, new InMemoryRawResult()); + $resultEvent2 = new ResultEvent($model, $deferred2, $invocationEvent2->getOptions()); + $processor->processResult($resultEvent2); + + $result2 = $resultEvent2->getDeferredResult()->asObject(); + $this->assertSame($city2, $result2); + + $this->assertSame('Berlin', $city1->name); + $this->assertSame(3500000, $city1->population); + $this->assertSame('Paris', $city2->name); + $this->assertSame(2161000, $city2->population); + } } diff --git a/src/platform/tests/StructuredOutput/ResultConverterTest.php b/src/platform/tests/StructuredOutput/ResultConverterTest.php new file mode 100644 index 0000000000..b769216e01 --- /dev/null +++ b/src/platform/tests/StructuredOutput/ResultConverterTest.php @@ -0,0 +1,156 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\StructuredOutput; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\PlainConverter; +use Symfony\AI\Platform\Result\InMemoryRawResult; +use Symfony\AI\Platform\Result\ObjectResult; +use Symfony\AI\Platform\Result\TextResult; +use Symfony\AI\Platform\StructuredOutput\ResultConverter; +use Symfony\AI\Platform\StructuredOutput\Serializer; +use Symfony\AI\Platform\Tests\Fixtures\StructuredOutput\City; +use Symfony\AI\Platform\Tests\Fixtures\StructuredOutput\SomeStructure; + +final class ResultConverterTest extends TestCase +{ + public function testConvertWithoutOutputType() + { + $innerConverter = new PlainConverter(new TextResult('{"key": "value"}')); + $converter = new ResultConverter($innerConverter, new Serializer()); + + $result = $converter->convert(new InMemoryRawResult()); + + $this->assertInstanceOf(ObjectResult::class, $result); + $this->assertIsArray($result->getContent()); + $this->assertSame(['key' => 'value'], $result->getContent()); + } + + public function testConvertWithOutputType() + { + $innerConverter = new PlainConverter(new TextResult('{"some": "data"}')); + $converter = new ResultConverter($innerConverter, new Serializer(), SomeStructure::class); + + $result = $converter->convert(new InMemoryRawResult()); + + $this->assertInstanceOf(ObjectResult::class, $result); + $this->assertInstanceOf(SomeStructure::class, $result->getContent()); + $this->assertSame('data', $result->getContent()->some); + } + + public function testConvertWithObjectToPopulate() + { + $city = new City(name: 'Berlin'); + $innerConverter = new PlainConverter(new TextResult('{"name": "Berlin", "population": 3500000, "country": "Germany", "mayor": "Kai Wegner"}')); + $converter = new ResultConverter($innerConverter, new Serializer(), City::class, $city); + + $result = $converter->convert(new InMemoryRawResult()); + + $this->assertInstanceOf(ObjectResult::class, $result); + $populatedCity = $result->getContent(); + + $this->assertSame($city, $populatedCity); + $this->assertSame('Berlin', $populatedCity->name); + $this->assertSame(3500000, $populatedCity->population); + $this->assertSame('Germany', $populatedCity->country); + $this->assertSame('Kai Wegner', $populatedCity->mayor); + } + + public function testConvertWithObjectToPopulatePreservesExistingValues() + { + $city = new City(name: 'Paris', country: 'France'); + $innerConverter = new PlainConverter(new TextResult('{"population": 2161000, "mayor": "Anne Hidalgo"}')); + $converter = new ResultConverter($innerConverter, new Serializer(), City::class, $city); + + $result = $converter->convert(new InMemoryRawResult()); + + $populatedCity = $result->getContent(); + + $this->assertInstanceOf(City::class, $populatedCity); + $this->assertSame($city, $populatedCity); + $this->assertSame('Paris', $populatedCity->name); + $this->assertSame('France', $populatedCity->country); + + $this->assertSame(2161000, $populatedCity->population); + $this->assertSame('Anne Hidalgo', $populatedCity->mayor); + } + + public function testConvertWithNullObjectToPopulateCreatesNewInstance() + { + $innerConverter = new PlainConverter(new TextResult('{"name": "Tokyo", "population": 13960000}')); + $converter = new ResultConverter($innerConverter, new Serializer(), City::class, null); + + $result = $converter->convert(new InMemoryRawResult()); + + $city = $result->getContent(); + + $this->assertInstanceOf(City::class, $city); + $this->assertSame('Tokyo', $city->name); + $this->assertSame(13960000, $city->population); + } + + public function testConvertSupportsAllModels() + { + $innerConverter = new PlainConverter(new TextResult('{}')); + $converter = new ResultConverter($innerConverter, new Serializer()); + + $this->assertTrue($converter->supports(new Model('any-model'))); + $this->assertTrue($converter->supports(new Model('gpt-4'))); + } + + public function testConvertReturnsNonTextResultUnchanged() + { + $objectResult = new ObjectResult(['data' => 'test']); + $innerConverter = new PlainConverter($objectResult); + $converter = new ResultConverter($innerConverter, new Serializer(), City::class); + + $result = $converter->convert(new InMemoryRawResult()); + + $this->assertSame($objectResult, $result); + } + + public function testConvertPreservesMetadataFromInnerResult() + { + $textResult = new TextResult('{"some": "data"}'); + $textResult->getMetadata()->add('test_key', 'test_value'); + + $innerConverter = new PlainConverter($textResult); + $converter = new ResultConverter($innerConverter, new Serializer(), SomeStructure::class); + + $result = $converter->convert(new InMemoryRawResult()); + + $this->assertTrue($result->getMetadata()->has('test_key')); + $this->assertSame('test_value', $result->getMetadata()->get('test_key')); + } + + public function testConvertSetsRawResultOnObjectResult() + { + $innerConverter = new PlainConverter(new TextResult('{"some": "data"}')); + $converter = new ResultConverter($innerConverter, new Serializer(), SomeStructure::class); + + $rawResult = new InMemoryRawResult(); + $result = $converter->convert($rawResult); + + $this->assertSame($rawResult, $result->getRawResult()); + } + + public function testGetTokenUsageExtractorDelegatesToInnerConverter() + { + $innerConverter = new PlainConverter(new TextResult('{}')); + $converter = new ResultConverter($innerConverter, new Serializer()); + + $extractor = $converter->getTokenUsageExtractor(); + + $this->assertNull($extractor); + } +}