Skip to content

Commit 5041847

Browse files
Add support for object instances in structured output
This change implements two features for structured output: 1. Object instances in response_format option - PlatformSubscriber now accepts both class-string and object instances - Uses Symfony Serializer's OBJECT_TO_POPULATE context - Preserves object identity (same instance returned) - Allows AI to populate missing data in partially-instantiated objects 2. Object parameters in Message::ofUser() - Accepts objects as last parameter for automatic context serialization - Object is JSON-serialized and appended to message content - Provides AI with current object state as context - Stores object reference in message metadata Usage example: ```php $city = new City(name: 'Berlin'); // Partial object $messages = new MessageBag( Message::ofUser('Research missing data for this city', $city), ); $result = $platform->invoke($messages, [ 'response_format' => $city, ]); // Returns same instance with all fields populated ``` Fixes #1545
1 parent 5596ce0 commit 5041847

File tree

9 files changed

+611
-9
lines changed

9 files changed

+611
-9
lines changed
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory;
13+
use Symfony\AI\Platform\Message\Message;
14+
use Symfony\AI\Platform\Message\MessageBag;
15+
use Symfony\AI\Platform\StructuredOutput\PlatformSubscriber;
16+
use Symfony\Component\EventDispatcher\EventDispatcher;
17+
18+
require_once dirname(__DIR__).'/bootstrap.php';
19+
20+
final class City
21+
{
22+
public function __construct(
23+
public ?string $name = null,
24+
public ?int $population = null,
25+
public ?string $country = null,
26+
public ?string $mayor = null,
27+
) {
28+
}
29+
}
30+
31+
$dispatcher = new EventDispatcher();
32+
$dispatcher->addSubscriber(new PlatformSubscriber());
33+
34+
$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client(), eventDispatcher: $dispatcher);
35+
36+
// Create a partially populated object with only the city name
37+
$city = new City(name: 'Berlin');
38+
39+
echo "Initial object state:\n";
40+
dump($city);
41+
42+
$messages = new MessageBag(
43+
Message::forSystem('You are a helpful assistant that provides information about cities.'),
44+
Message::ofUser('Please research the missing data attributes for this city', $city),
45+
);
46+
47+
$result = $platform->invoke('gpt-5-mini', $messages, [
48+
'response_format' => $city,
49+
]);
50+
51+
echo "\nPopulated object state:\n";
52+
dump($result->asObject());
53+
54+
// Verify that the same object instance was populated
55+
echo "\nObject identity preserved: ";
56+
var_dump($city === $result->asObject());

src/platform/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ CHANGELOG
44
0.3
55
---
66

7+
* Add support for populating existing object instances in structured output via `response_format` option
8+
* Add support for passing objects to `Message::ofUser()` for automatic serialization as context
79
* Add `StreamListenerInterface` to hook into response streams
810
* [BC BREAK] Change `TokenUsageAggregation::__construct()` from variadic to array
911
* Add `TokenUsageAggregation::add()` method to add more token usages

src/platform/src/Message/Message.php

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,22 @@ public static function ofAssistant(?string $content = null, ?array $toolCalls =
4343
return new AssistantMessage($content, $toolCalls);
4444
}
4545

46-
public static function ofUser(\Stringable|string|ContentInterface ...$content): UserMessage
46+
public static function ofUser(mixed ...$content): UserMessage
4747
{
48+
$contextObject = null;
49+
if (\count($content) > 0) {
50+
$lastArg = $content[\count($content) - 1];
51+
if (\is_object($lastArg) && !$lastArg instanceof \Stringable && !$lastArg instanceof ContentInterface) {
52+
$contextObject = array_pop($content);
53+
}
54+
}
55+
56+
foreach ($content as $entry) {
57+
if (!\is_string($entry) && !$entry instanceof \Stringable && !$entry instanceof ContentInterface) {
58+
throw new \InvalidArgumentException(\sprintf('Content must be string, Stringable, or ContentInterface, %s given.', get_debug_type($entry)));
59+
}
60+
}
61+
4862
$content = array_map(
4963
static fn (\Stringable|string|ContentInterface $entry) => match (true) {
5064
$entry instanceof ContentInterface => $entry,
@@ -54,7 +68,17 @@ public static function ofUser(\Stringable|string|ContentInterface ...$content):
5468
$content,
5569
);
5670

57-
return new UserMessage(...$content);
71+
if (null !== $contextObject) {
72+
$content[] = new Text(json_encode($contextObject, \JSON_PRETTY_PRINT | \JSON_THROW_ON_ERROR));
73+
}
74+
75+
$message = new UserMessage(...$content);
76+
77+
if (null !== $contextObject) {
78+
$message->getMetadata()->add('structured_output_object', $contextObject);
79+
}
80+
81+
return $message;
5882
}
5983

6084
public static function ofToolCall(ToolCall $toolCall, string $content): ToolCallMessage

src/platform/src/StructuredOutput/PlatformSubscriber.php

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ final class PlatformSubscriber implements EventSubscriberInterface
2929

3030
private string $outputType;
3131

32+
private ?object $objectToPopulate = null;
33+
3234
private SerializerInterface $serializer;
3335

3436
public function __construct(
@@ -54,11 +56,19 @@ public function processInput(InvocationEvent $event): void
5456
{
5557
$options = $event->getOptions();
5658

57-
if (!isset($options[self::RESPONSE_FORMAT]) || !\is_string($options[self::RESPONSE_FORMAT])) {
59+
if (!isset($options[self::RESPONSE_FORMAT])) {
5860
return;
5961
}
6062

61-
if (!class_exists($options[self::RESPONSE_FORMAT])) {
63+
$responseFormat = $options[self::RESPONSE_FORMAT];
64+
65+
if (\is_object($responseFormat)) {
66+
$this->objectToPopulate = $responseFormat;
67+
$className = $responseFormat::class;
68+
} elseif (\is_string($responseFormat) && class_exists($responseFormat)) {
69+
$this->objectToPopulate = null;
70+
$className = $responseFormat;
71+
} else {
6272
return;
6373
}
6474

@@ -70,9 +80,9 @@ public function processInput(InvocationEvent $event): void
7080
throw MissingModelSupportException::forStructuredOutput($event->getModel());
7181
}
7282

73-
$this->outputType = $options[self::RESPONSE_FORMAT];
83+
$this->outputType = $className;
7484

75-
$options[self::RESPONSE_FORMAT] = $this->responseFormatFactory->create($options[self::RESPONSE_FORMAT]);
85+
$options[self::RESPONSE_FORMAT] = $this->responseFormatFactory->create($className);
7686

7787
$event->setOptions($options);
7888
}
@@ -86,8 +96,16 @@ public function processResult(ResultEvent $event): void
8696
}
8797

8898
$deferred = $event->getDeferredResult();
89-
$converter = new ResultConverter($deferred->getResultConverter(), $this->serializer, $this->outputType ?? null);
99+
$converter = new ResultConverter(
100+
$deferred->getResultConverter(),
101+
$this->serializer,
102+
$this->outputType ?? null,
103+
$this->objectToPopulate
104+
);
90105

91106
$event->setDeferredResult(new DeferredResult($converter, $deferred->getRawResult(), $options));
107+
108+
// Reset object to populate for next invocation
109+
$this->objectToPopulate = null;
92110
}
93111
}

src/platform/src/StructuredOutput/ResultConverter.php

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use Symfony\AI\Platform\ResultConverterInterface;
2121
use Symfony\AI\Platform\TokenUsage\TokenUsageExtractorInterface;
2222
use Symfony\Component\Serializer\Exception\ExceptionInterface as SerializerExceptionInterface;
23+
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
2324
use Symfony\Component\Serializer\SerializerInterface;
2425

2526
final class ResultConverter implements ResultConverterInterface
@@ -28,6 +29,7 @@ public function __construct(
2829
private readonly ResultConverterInterface $innerConverter,
2930
private readonly SerializerInterface $serializer,
3031
private readonly ?string $outputType = null,
32+
private readonly ?object $objectToPopulate = null,
3133
) {
3234
}
3335

@@ -45,8 +47,19 @@ public function convert(RawResultInterface $result, array $options = []): Result
4547
}
4648

4749
try {
48-
$structure = null === $this->outputType ? json_decode($innerResult->getContent(), true, flags: \JSON_THROW_ON_ERROR)
49-
: $this->serializer->deserialize($innerResult->getContent(), $this->outputType, 'json');
50+
$context = [];
51+
if (null !== $this->objectToPopulate) {
52+
$context[AbstractNormalizer::OBJECT_TO_POPULATE] = $this->objectToPopulate;
53+
}
54+
55+
$structure = null === $this->outputType
56+
? json_decode($innerResult->getContent(), true, flags: \JSON_THROW_ON_ERROR)
57+
: $this->serializer->deserialize(
58+
$innerResult->getContent(),
59+
$this->outputType,
60+
'json',
61+
$context
62+
);
5063
} catch (\JsonException $e) {
5164
throw new RuntimeException('Cannot json decode the content.', previous: $e);
5265
} catch (SerializerExceptionInterface $e) {
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Platform\Tests\Fixtures\StructuredOutput;
13+
14+
final class City
15+
{
16+
public function __construct(
17+
public ?string $name = null,
18+
public ?int $population = null,
19+
public ?string $country = null,
20+
public ?string $mayor = null,
21+
) {
22+
}
23+
}

src/platform/tests/Message/MessageTest.php

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use Symfony\AI\Platform\Message\Content\Text;
1818
use Symfony\AI\Platform\Message\Message;
1919
use Symfony\AI\Platform\Result\ToolCall;
20+
use Symfony\AI\Platform\Tests\Fixtures\StructuredOutput\City;
2021

2122
final class MessageTest extends TestCase
2223
{
@@ -122,4 +123,117 @@ public function testCreateToolCallMessage()
122123
$this->assertSame('Foo bar.', $message->getContent());
123124
$this->assertSame($toolCall, $message->getToolCall());
124125
}
126+
127+
public function testCreateUserMessageWithObjectSerializesItAsContext()
128+
{
129+
$city = new City(name: 'Berlin', population: 3500000);
130+
$message = Message::ofUser('Please research missing data', $city);
131+
132+
$content = $message->getContent();
133+
$this->assertCount(2, $content);
134+
135+
$this->assertInstanceOf(Text::class, $content[0]);
136+
$this->assertSame('Please research missing data', $content[0]->getText());
137+
138+
$this->assertInstanceOf(Text::class, $content[1]);
139+
$serializedContent = $content[1]->getText();
140+
$this->assertStringContainsString('Berlin', $serializedContent);
141+
$this->assertStringContainsString('3500000', $serializedContent);
142+
$this->assertStringContainsString('"name"', $serializedContent);
143+
$this->assertStringContainsString('"population"', $serializedContent);
144+
}
145+
146+
public function testCreateUserMessageWithObjectStoresItInMetadata()
147+
{
148+
$city = new City(name: 'Berlin');
149+
$message = Message::ofUser('Research data', $city);
150+
151+
$this->assertTrue($message->getMetadata()->has('structured_output_object'));
152+
$this->assertSame($city, $message->getMetadata()->get('structured_output_object'));
153+
}
154+
155+
public function testCreateUserMessageWithMultipleContentAndObject()
156+
{
157+
$city = new City(name: 'Paris');
158+
$message = Message::ofUser(
159+
'Check this city',
160+
new ImageUrl('http://example.com/paris.jpg'),
161+
$city
162+
);
163+
164+
$content = $message->getContent();
165+
$this->assertCount(3, $content);
166+
167+
$this->assertInstanceOf(Text::class, $content[0]);
168+
$this->assertInstanceOf(ImageUrl::class, $content[1]);
169+
170+
$this->assertInstanceOf(Text::class, $content[2]);
171+
$this->assertStringContainsString('Paris', $content[2]->getText());
172+
}
173+
174+
public function testCreateUserMessageWithOnlyObject()
175+
{
176+
$city = new City(name: 'Tokyo');
177+
$message = Message::ofUser($city);
178+
179+
$content = $message->getContent();
180+
$this->assertCount(1, $content);
181+
182+
$this->assertInstanceOf(Text::class, $content[0]);
183+
$serializedContent = $content[0]->getText();
184+
$this->assertStringContainsString('Tokyo', $serializedContent);
185+
$this->assertStringContainsString('"name"', $serializedContent);
186+
}
187+
188+
public function testCreateUserMessageWithStringableIsNotTreatedAsObject()
189+
{
190+
$stringable = new class implements \Stringable {
191+
public function __toString(): string
192+
{
193+
return 'I am stringable';
194+
}
195+
};
196+
197+
$message = Message::ofUser('Hello', $stringable);
198+
199+
$content = $message->getContent();
200+
$this->assertCount(2, $content);
201+
202+
$this->assertInstanceOf(Text::class, $content[0]);
203+
$this->assertInstanceOf(Text::class, $content[1]);
204+
$this->assertSame('I am stringable', $content[1]->getText());
205+
206+
$this->assertFalse($message->getMetadata()->has('structured_output_object'));
207+
}
208+
209+
public function testCreateUserMessageWithContentInterfaceIsNotTreatedAsObject()
210+
{
211+
$imageUrl = new ImageUrl('http://example.com/image.jpg');
212+
$message = Message::ofUser('Look at this', $imageUrl);
213+
214+
$content = $message->getContent();
215+
$this->assertCount(2, $content);
216+
217+
$this->assertInstanceOf(Text::class, $content[0]);
218+
$this->assertInstanceOf(ImageUrl::class, $content[1]);
219+
220+
$this->assertFalse($message->getMetadata()->has('structured_output_object'));
221+
}
222+
223+
public function testCreateUserMessageThrowsExceptionForInvalidContentType()
224+
{
225+
$this->expectException(\InvalidArgumentException::class);
226+
$this->expectExceptionMessage('Content must be string, Stringable, or ContentInterface');
227+
228+
Message::ofUser('Hello', ['invalid' => 'array']);
229+
}
230+
231+
public function testCreateUserMessageWithObjectAndInvalidTypeThrowsException()
232+
{
233+
$this->expectException(\InvalidArgumentException::class);
234+
$this->expectExceptionMessage('Content must be string, Stringable, or ContentInterface');
235+
236+
$city = new City(name: 'London');
237+
Message::ofUser(123, 'text', $city);
238+
}
125239
}

0 commit comments

Comments
 (0)