Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
75 changes: 75 additions & 0 deletions docs/components/platform.rst
Original file line number Diff line number Diff line change
Expand Up @@ -489,11 +489,85 @@ 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::

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');

// Pass the object instance to response_format
$messages = new MessageBag(
Message::forSystem('You are a helpful assistant that provides information about cities.'),
Message::ofUser('Please provide the population, country, and current mayor for Berlin.'),
);

$result = $platform->invoke('gpt-4o-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

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
Comment on lines +542 to +546
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i feel stuff like that would rather belong in a cookbook article


Object Context in Messages
~~~~~~~~~~~~~~~~~~~~~~~~~~

You can also pass objects directly to user messages for automatic serialization as context.
The object will be JSON-serialized and included in the message content::

use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;

$city = new City(name: 'Berlin'); // Partial object

// Pass object as last parameter to Message::ofUser()
$messages = new MessageBag(
Message::forSystem('You are a helpful assistant that provides information about cities.'),
Message::ofUser('Research missing data for this city', $city), // Object as context
);

$result = $platform->invoke('gpt-4o-mini', $messages, [
'response_format' => $city, // Populate the same object
]);

// The AI sees the serialized object state and fills in missing data
$populatedCity = $result->asObject();

The object is serialized to JSON and appended to the message, providing the AI with the current state of the object.
This makes it clear what data is already present and what needs to be filled.

Code Examples
~~~~~~~~~~~~~

* `Structured Output with PHP class`_
* `Structured Output with array`_
* `Populating existing objects`_

Server Tools
------------
Expand Down Expand Up @@ -748,6 +822,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/
Expand Down
46 changes: 46 additions & 0 deletions examples/platform/structured-output-populate-object.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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());
2 changes: 2 additions & 0 deletions src/platform/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 27 additions & 2 deletions src/platform/src/Message/Message.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
28 changes: 23 additions & 5 deletions src/platform/src/StructuredOutput/PlatformSubscriber.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ final class PlatformSubscriber implements EventSubscriberInterface

private string $outputType;

private ?object $objectToPopulate = null;

private SerializerInterface $serializer;

public function __construct(
Expand All @@ -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;
}

Expand All @@ -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);
}
Expand All @@ -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;
}
}
17 changes: 15 additions & 2 deletions src/platform/src/StructuredOutput/ResultConverter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
) {
}

Expand All @@ -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) {
Expand Down
23 changes: 23 additions & 0 deletions src/platform/tests/Fixtures/StructuredOutput/City.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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,
) {
}
}
Loading