diff --git a/docs/components/chat.rst b/docs/components/chat.rst index cf1b046f9..1384a3481 100644 --- a/docs/components/chat.rst +++ b/docs/components/chat.rst @@ -38,6 +38,7 @@ You can find more advanced usage in combination with an Agent using the store fo * `Current process context storage with InMemory`_ * `Long-term context with Meilisearch`_ * `Long-term context with Pogocache`_ +* `Long-term context with Redis`_ Supported Message stores ------------------------ @@ -47,6 +48,7 @@ Supported Message stores * `InMemory`_ * `Meilisearch`_ * `Pogocache`_ +* `Redis`_ Implementing a Bridge --------------------- @@ -127,8 +129,10 @@ store and ``bin/console ai:message-store:drop`` to clean up the message store: .. _`Current process context storage with InMemory`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat.php .. _`Long-term context with Meilisearch`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-meilisearch.php .. _`Long-term context with Pogocache`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-pogocache.php +.. _`Long-term context with Redis`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-redis.php .. _`Cache`: https://symfony.com/doc/current/components/cache.html .. _`InMemory`: https://www.php.net/manual/en/language.types.array.php .. _`HttpFoundation session`: https://developers.cloudflare.com/vectorize/ .. _`Meilisearch`: https://www.meilisearch.com/ .. _`Pogocache`: https://pogocache.com/ +.. _`Redis`: https://redis.io/ diff --git a/examples/.env b/examples/.env index f7ad3c198..e57e3a43a 100644 --- a/examples/.env +++ b/examples/.env @@ -169,5 +169,5 @@ SUPABASE_MATCH_FUNCTION=match_documents POGOCACHE_HOST=http://127.0.0.1:9401 POGOCACHE_PASSWORD=symfony -# Redis (store) +# Redis (both store and message store) REDIS_HOST=localhost diff --git a/examples/chat/persistent-chat-redis.php b/examples/chat/persistent-chat-redis.php new file mode 100644 index 000000000..4d3382fcc --- /dev/null +++ b/examples/chat/persistent-chat-redis.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Agent\Agent; +use Symfony\AI\Chat\Bridge\Redis\MessageStore; +use Symfony\AI\Chat\Chat; +use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client()); + +$redis = new Redis([ + 'host' => env('REDIS_HOST'), + 'port' => 6379, +]); + +$store = new MessageStore($redis, 'symfony'); +$store->setup(); + +$agent = new Agent($platform, 'gpt-4o-mini'); +$chat = new Chat($agent, $store); + +$messages = new MessageBag( + Message::forSystem('You are a helpful assistant. You only answer with short sentences.'), +); + +$chat->initiate($messages); +$chat->submit(Message::ofUser('My name is Christopher.')); +$message = $chat->submit(Message::ofUser('What is my name?')); + +echo $message->getContent().\PHP_EOL; diff --git a/examples/commands/message-stores.php b/examples/commands/message-stores.php index a5445704a..29ea9038a 100644 --- a/examples/commands/message-stores.php +++ b/examples/commands/message-stores.php @@ -16,8 +16,10 @@ use Symfony\AI\Chat\Bridge\Local\InMemoryStore; use Symfony\AI\Chat\Bridge\Meilisearch\MessageStore as MeilisearchMessageStore; use Symfony\AI\Chat\Bridge\Pogocache\MessageStore as PogocacheMessageStore; +use Symfony\AI\Chat\Bridge\Redis\MessageStore as RedisMessageStore; use Symfony\AI\Chat\Command\DropStoreCommand; use Symfony\AI\Chat\Command\SetupStoreCommand; +use Symfony\AI\Chat\MessageNormalizer; use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\Clock\MonotonicClock; use Symfony\Component\Console\Application; @@ -28,6 +30,9 @@ use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; +use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer; +use Symfony\Component\Serializer\Serializer; $factories = [ 'cache' => static fn (): CacheStore => new CacheStore(new ArrayAdapter(), cacheKey: 'symfony'), @@ -45,6 +50,15 @@ env('POGOCACHE_PASSWORD'), 'symfony', ), + 'redis' => static fn (): RedisMessageStore => new RedisMessageStore(new Redis([ + 'host' => env('REDIS_HOST'), + 'port' => 6379, + ]), 'symfony', new Serializer([ + new ArrayDenormalizer(), + new MessageNormalizer(), + ], [ + new JsonEncoder(), + ])), 'session' => static function (): SessionStore { $request = Request::create('/'); $request->setSession(new Session(new MockArraySessionStorage())); diff --git a/examples/commands/stores.php b/examples/commands/stores.php index d490246df..e0053eb8b 100644 --- a/examples/commands/stores.php +++ b/examples/commands/stores.php @@ -89,13 +89,10 @@ env('QDRANT_SERVICE_API_KEY'), 'symfony', ), - 'redis' => static fn (): RedisStore => new RedisStore( - new Redis([ - 'host' => env('REDIS_HOST'), - 'port' => 6379, - ]), - 'symfony' - ), + 'redis' => static fn (): RedisStore => new RedisStore(new Redis([ + 'host' => env('REDIS_HOST'), + 'port' => 6379, + ]), 'symfony'), 'surrealdb' => static fn (): SurrealDbStore => new SurrealDbStore( httpClient: http_client(), endpointUrl: env('SURREALDB_HOST'), diff --git a/examples/compose.yaml b/examples/compose.yaml index 8738805f0..5e01b4df3 100644 --- a/examples/compose.yaml +++ b/examples/compose.yaml @@ -133,7 +133,7 @@ services: - '6333:6333' redis: - image: redis:8.0.3 + image: redis:8.2.2-alpine ports: - '6379:6379' diff --git a/examples/composer.json b/examples/composer.json index 6ff168896..a99c4cd3f 100644 --- a/examples/composer.json +++ b/examples/composer.json @@ -33,6 +33,7 @@ "symfony/finder": "^7.3|^8.0", "symfony/http-foundation": "^7.3|^8.0", "symfony/process": "^7.3|^8.0", + "symfony/serializer": "^7.3|^8.0", "symfony/var-dumper": "^7.3|^8.0" }, "require-dev": { diff --git a/src/ai-bundle/config/options.php b/src/ai-bundle/config/options.php index af08abe5a..fb92b7f7b 100644 --- a/src/ai-bundle/config/options.php +++ b/src/ai-bundle/config/options.php @@ -765,6 +765,31 @@ ->end() ->end() ->end() + ->arrayNode('redis') + ->useAttributeAsKey('name') + ->arrayPrototype() + ->children() + ->variableNode('connection_parameters') + ->info('see https://github.com/phpredis/phpredis?tab=readme-ov-file#example-1') + ->cannotBeEmpty() + ->end() + ->stringNode('client') + ->info('a service id of a Redis client') + ->cannotBeEmpty() + ->end() + ->stringNode('endpoint')->cannotBeEmpty()->end() + ->stringNode('index_name')->cannotBeEmpty()->end() + ->end() + ->validate() + ->ifTrue(static fn (array $v): bool => !isset($v['connection_parameters']) && !isset($v['client'])) + ->thenInvalid('Either "connection_parameters" or "client" must be configured.') + ->end() + ->validate() + ->ifTrue(static fn (array $v): bool => isset($v['connection_parameters']) && isset($v['client'])) + ->thenInvalid('Either "connection_parameters" or "client" can be configured, but not both.') + ->end() + ->end() + ->end() ->arrayNode('session') ->useAttributeAsKey('name') ->arrayPrototype() diff --git a/src/ai-bundle/config/services.php b/src/ai-bundle/config/services.php index 1de2fad69..eace5fb61 100644 --- a/src/ai-bundle/config/services.php +++ b/src/ai-bundle/config/services.php @@ -23,6 +23,7 @@ use Symfony\AI\AiBundle\Security\EventListener\IsGrantedToolAttributeListener; use Symfony\AI\Chat\Command\DropStoreCommand as DropMessageStoreCommand; use Symfony\AI\Chat\Command\SetupStoreCommand as SetupMessageStoreCommand; +use Symfony\AI\Chat\MessageNormalizer; use Symfony\AI\Platform\Bridge\AiMlApi\ModelCatalog as AiMlApiModelCatalog; use Symfony\AI\Platform\Bridge\Anthropic\Contract\AnthropicContract; use Symfony\AI\Platform\Bridge\Anthropic\ModelCatalog as AnthropicModelCatalog; @@ -182,6 +183,10 @@ // search result processors ->set('ai.platform.search_result_processor.perplexity', PerplexitySearchResultProcessor::class) + // serializer + ->set('ai.chat.message_bag.normalizer', MessageNormalizer::class) + ->tag('serializer.normalizer') + // commands ->set('ai.command.chat', AgentCallCommand::class) ->args([ diff --git a/src/ai-bundle/src/AiBundle.php b/src/ai-bundle/src/AiBundle.php index b96e960b6..df55772ca 100644 --- a/src/ai-bundle/src/AiBundle.php +++ b/src/ai-bundle/src/AiBundle.php @@ -37,6 +37,7 @@ use Symfony\AI\Chat\Bridge\HttpFoundation\SessionStore; use Symfony\AI\Chat\Bridge\Meilisearch\MessageStore as MeilisearchMessageStore; use Symfony\AI\Chat\Bridge\Pogocache\MessageStore as PogocacheMessageStore; +use Symfony\AI\Chat\Bridge\Redis\MessageStore as RedisMessageStore; use Symfony\AI\Chat\MessageStoreInterface; use Symfony\AI\Platform\Bridge\Anthropic\PlatformFactory as AnthropicPlatformFactory; use Symfony\AI\Platform\Bridge\Azure\OpenAi\PlatformFactory as AzureOpenAiPlatformFactory; @@ -1393,6 +1394,30 @@ private function processMessageStoreConfig(string $type, array $messageStores, C } } + if ('redis' === $type) { + foreach ($messageStores as $name => $messageStore) { + if (isset($messageStore['client'])) { + $redisClient = new Reference($messageStore['client']); + } else { + $redisClient = new Definition(\Redis::class); + $redisClient->setArguments([$messageStore['connection_parameters']]); + } + + $definition = new Definition(RedisMessageStore::class); + $definition + ->setArguments([ + $redisClient, + $messageStore['index_name'], + new Reference('serializer'), + ]) + ->addTag('ai.message_store'); + + $container->setDefinition('ai.message_store.'.$type.'.'.$name, $definition); + $container->registerAliasForArgument('ai.message_store.'.$type.'.'.$name, MessageStoreInterface::class, $name); + $container->registerAliasForArgument('ai.message_store.'.$type.'.'.$name, MessageStoreInterface::class, $type.'_'.$name); + } + } + if ('session' === $type) { foreach ($messageStores as $name => $messageStore) { $definition = new Definition(SessionStore::class); diff --git a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php index 4d7438ea4..36056cb12 100644 --- a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php +++ b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php @@ -145,6 +145,13 @@ public function testMessageStoreCommandsAreDefined() $this->assertArrayHasKey('console.command', $dropStoreCommandDefinition->getTags()); } + public function testMessageBagNormalizerIsRegistered() + { + $container = $this->buildContainer($this->getFullConfig()); + + $this->assertTrue($container->hasDefinition('ai.chat.message_bag.normalizer')); + } + public function testInjectionAgentAliasIsRegistered() { $container = $this->buildContainer([ @@ -3058,6 +3065,15 @@ private function getFullConfig(): array 'key' => 'bar', ], ], + 'redis' => [ + 'my_redis_store' => [ + 'connection_parameters' => [ + 'host' => '1.2.3.4', + 'port' => 6379, + ], + 'index_name' => 'my_message_store', + ], + ], 'session' => [ 'my_session_message_store' => [ 'identifier' => 'session', diff --git a/src/chat/CHANGELOG.md b/src/chat/CHANGELOG.md index dfd4e85a5..cfb645f06 100644 --- a/src/chat/CHANGELOG.md +++ b/src/chat/CHANGELOG.md @@ -5,3 +5,7 @@ CHANGELOG --- * Introduce the component + * Add support for external message stores: + - Meilisearch + - Pogocache + - Redis diff --git a/src/chat/composer.json b/src/chat/composer.json index f96ed04e5..cb3a73c52 100644 --- a/src/chat/composer.json +++ b/src/chat/composer.json @@ -25,14 +25,16 @@ "symfony/ai-platform": "@dev" }, "require-dev": { + "ext-redis": "*", "phpstan/phpstan": "^2.0", "phpstan/phpstan-strict-rules": "^2.0", "phpunit/phpunit": "^11.5.13", + "psr/cache": "^3.0", "symfony/dependency-injection": "^7.3|^8.0", "symfony/clock": "^7.3|^8.0", "symfony/console": "^7.3|^8.0", "symfony/http-foundation": "^7.3|^8.0", - "psr/cache": "^3.0" + "symfony/serializer": "^7.3|^8.0" }, "suggest": { "symfony/clock": "To use stores that requires waiting for actions like Meilisearch" diff --git a/src/chat/src/Bridge/Redis/MessageStore.php b/src/chat/src/Bridge/Redis/MessageStore.php new file mode 100644 index 000000000..d8d964fa2 --- /dev/null +++ b/src/chat/src/Bridge/Redis/MessageStore.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Chat\Bridge\Redis; + +use Symfony\AI\Chat\ManagedStoreInterface; +use Symfony\AI\Chat\MessageNormalizer; +use Symfony\AI\Chat\MessageStoreInterface; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\Message\MessageInterface; +use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer; +use Symfony\Component\Serializer\Serializer; +use Symfony\Component\Serializer\SerializerInterface; + +/** + * @author Guillaume Loulier + */ +final class MessageStore implements ManagedStoreInterface, MessageStoreInterface +{ + public function __construct( + private readonly \Redis $redis, + private readonly string $indexName, + private readonly SerializerInterface $serializer = new Serializer([ + new ArrayDenormalizer(), + new MessageNormalizer(), + ], [new JsonEncoder()]), + ) { + } + + public function setup(array $options = []): void + { + if ($this->redis->exists($this->indexName)) { + return; + } + + $this->redis->set($this->indexName, $this->serializer->serialize([], 'json')); + } + + public function drop(): void + { + $this->redis->set($this->indexName, $this->serializer->serialize([], 'json')); + } + + public function save(MessageBag $messages): void + { + $this->redis->set($this->indexName, $this->serializer->serialize($messages->getMessages(), 'json')); + } + + public function load(): MessageBag + { + return new MessageBag(...$this->serializer->deserialize($this->redis->get($this->indexName), MessageInterface::class.'[]', 'json')); + } +} diff --git a/src/chat/src/MessageNormalizer.php b/src/chat/src/MessageNormalizer.php new file mode 100644 index 000000000..d74c6bf13 --- /dev/null +++ b/src/chat/src/MessageNormalizer.php @@ -0,0 +1,145 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Chat; + +use Symfony\AI\Chat\Exception\LogicException; +use Symfony\AI\Platform\Message\AssistantMessage; +use Symfony\AI\Platform\Message\Content\Audio; +use Symfony\AI\Platform\Message\Content\ContentInterface; +use Symfony\AI\Platform\Message\Content\DocumentUrl; +use Symfony\AI\Platform\Message\Content\File; +use Symfony\AI\Platform\Message\Content\Image; +use Symfony\AI\Platform\Message\Content\ImageUrl; +use Symfony\AI\Platform\Message\Content\Text; +use Symfony\AI\Platform\Message\MessageInterface; +use Symfony\AI\Platform\Message\SystemMessage; +use Symfony\AI\Platform\Message\ToolCallMessage; +use Symfony\AI\Platform\Message\UserMessage; +use Symfony\AI\Platform\Result\ToolCall; +use Symfony\Component\Serializer\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * @author Guillaume Loulier + */ +final class MessageNormalizer implements NormalizerInterface, DenormalizerInterface +{ + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed + { + if ([] === $data) { + throw new InvalidArgumentException('The current message bag data are not coherent.'); + } + + $type = $data['type']; + $content = $data['content'] ?? ''; + $contentAsBase64 = $data['contentAsBase64'] ?? []; + + $message = match ($type) { + SystemMessage::class => new SystemMessage($content), + AssistantMessage::class => new AssistantMessage($content, array_map( + static fn (array $toolsCall): ToolCall => new ToolCall( + $toolsCall['id'], + $toolsCall['function']['name'], + json_decode($toolsCall['function']['arguments'], true) + ), + $data['toolsCalls'], + )), + UserMessage::class => new UserMessage(...array_map( + static fn (array $contentAsBase64): ContentInterface => \in_array($contentAsBase64['type'], [File::class, Image::class, Audio::class], true) + ? $contentAsBase64['type']::fromDataUrl($contentAsBase64['content']) + : new $contentAsBase64['type']($contentAsBase64['content']), + $contentAsBase64, + )), + ToolCallMessage::class => new ToolCallMessage( + new ToolCall( + $data['toolsCalls']['id'], + $data['toolsCalls']['function']['name'], + json_decode($data['toolsCalls']['function']['arguments'], true) + ), + $content + ), + default => throw new LogicException(\sprintf('Unknown message type "%s".', $type)), + }; + + $message->getMetadata()->set([ + ...$data['metadata'], + 'addedAt' => $data['addedAt'], + ]); + + return $message; + } + + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool + { + return MessageInterface::class === $type; + } + + /** + * @return array + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + if (!$data instanceof MessageInterface) { + return []; + } + + $toolsCalls = []; + + if ($data instanceof AssistantMessage && $data->hasToolCalls()) { + $toolsCalls = array_map( + static fn (ToolCall $toolCall): array => $toolCall->jsonSerialize(), + $data->getToolCalls(), + ); + } + + if ($data instanceof ToolCallMessage) { + $toolsCalls = $data->getToolCall()->jsonSerialize(); + } + + return [ + 'id' => $data->getId()->toRfc4122(), + 'type' => $data::class, + 'content' => ($data instanceof SystemMessage || $data instanceof AssistantMessage || $data instanceof ToolCallMessage) ? $data->getContent() : '', + 'contentAsBase64' => ($data instanceof UserMessage && [] !== $data->getContent()) ? array_map( + static fn (ContentInterface $content) => [ + 'type' => $content::class, + 'content' => match ($content::class) { + Text::class => $content->getText(), + File::class, + Image::class, + Audio::class => $content->asBase64(), + ImageUrl::class, + DocumentUrl::class => $content->getUrl(), + default => throw new LogicException(\sprintf('Unknown content type "%s".', $content::class)), + }, + ], + $data->getContent(), + ) : [], + 'toolsCalls' => $toolsCalls, + 'metadata' => $data->getMetadata()->all(), + 'addedAt' => (new \DateTimeImmutable())->getTimestamp(), + ]; + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $data instanceof MessageInterface; + } + + public function getSupportedTypes(?string $format): array + { + return [ + MessageInterface::class => true, + ]; + } +} diff --git a/src/chat/tests/Bridge/Redis/MessageStoreTest.php b/src/chat/tests/Bridge/Redis/MessageStoreTest.php new file mode 100644 index 000000000..7860883cd --- /dev/null +++ b/src/chat/tests/Bridge/Redis/MessageStoreTest.php @@ -0,0 +1,93 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Chat\Tests\Bridge\Redis; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\Chat\Bridge\Redis\MessageStore; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\Message\SystemMessage; +use Symfony\Component\Serializer\SerializerInterface; + +final class MessageStoreTest extends TestCase +{ + public function testStoreCannotSetupOnExistingItem() + { + $serializer = $this->createMock(SerializerInterface::class); + $serializer->expects($this->never())->method('serialize'); + + $redis = $this->createMock(\Redis::class); + $redis->expects($this->once())->method('exists')->willReturn(true); + + $store = new MessageStore($redis, 'test', $serializer); + $store->setup(); + } + + public function testStoreCanSetup() + { + $serializer = $this->createMock(SerializerInterface::class); + $serializer->expects($this->once())->method('serialize')->willReturn(''); + + $redis = $this->createMock(\Redis::class); + $redis->expects($this->once())->method('exists')->willReturn(false); + $redis->expects($this->once())->method('set'); + + $store = new MessageStore($redis, 'test', $serializer); + $store->setup(); + } + + public function testStoreCanDrop() + { + $serializer = $this->createMock(SerializerInterface::class); + $serializer->expects($this->once())->method('serialize')->willReturn(''); + + $redis = $this->createMock(\Redis::class); + $redis->expects($this->never())->method('exists'); + $redis->expects($this->once())->method('set'); + + $store = new MessageStore($redis, 'test', $serializer); + $store->drop(); + } + + public function testStoreCanSave() + { + $serializer = $this->createMock(SerializerInterface::class); + $serializer->expects($this->once())->method('serialize')->willReturn('[{"id":"019a24c5-67a3-7f08-a670-5d30958d439f","type":"Symfony\\AI\\Platform\\Message\\SystemMessage","content":"You are a helpful assistant. You only answer with short sentences.","contentAsBase64":[],"toolsCalls":[],"metadata":[],"addedAt":1761553508}]'); + + $redis = $this->createMock(\Redis::class); + $redis->expects($this->never())->method('exists'); + $redis->expects($this->once())->method('set'); + + $store = new MessageStore($redis, 'test', $serializer); + $store->save(new MessageBag(Message::ofUser('Hello there'))); + } + + public function testStoreCanLoad() + { + $serializer = $this->createMock(SerializerInterface::class); + $serializer->expects($this->once())->method('serialize')->willReturn('[{"id":"019a24c5-67a3-7f08-a670-5d30958d439f","type":"Symfony\\AI\\Platform\\Message\\SystemMessage","content":"You are a helpful assistant. You only answer with short sentences.","contentAsBase64":[],"toolsCalls":[],"metadata":[],"addedAt":1761553508}]'); + $serializer->expects($this->once())->method('deserialize')->willReturn([ + new SystemMessage('You are a helpful assistant. You only answer with short sentences.'), + ]); + + $redis = $this->createMock(\Redis::class); + $redis->expects($this->never())->method('exists'); + $redis->expects($this->once())->method('set'); + + $store = new MessageStore($redis, 'test', $serializer); + $store->save(new MessageBag(Message::ofUser('Hello there'))); + + $messageBag = $store->load(); + + $this->assertCount(1, $messageBag); + } +} diff --git a/src/chat/tests/MessageNormalizerTest.php b/src/chat/tests/MessageNormalizerTest.php new file mode 100644 index 000000000..a96a0192d --- /dev/null +++ b/src/chat/tests/MessageNormalizerTest.php @@ -0,0 +1,77 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Chat\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\Chat\MessageNormalizer; +use Symfony\AI\Platform\Message\Content\Text; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageInterface; +use Symfony\AI\Platform\Message\Role; +use Symfony\AI\Platform\Message\UserMessage; +use Symfony\Component\Uid\Uuid; + +final class MessageNormalizerTest extends TestCase +{ + public function testItIsConfigured() + { + $normalizer = new MessageNormalizer(); + + $this->assertSame([ + MessageInterface::class => true, + ], $normalizer->getSupportedTypes('')); + + $this->assertFalse($normalizer->supportsNormalization(new \stdClass())); + $this->assertTrue($normalizer->supportsNormalization(Message::ofUser())); + + $this->assertFalse($normalizer->supportsDenormalization('', \stdClass::class)); + $this->assertTrue($normalizer->supportsDenormalization('', MessageInterface::class)); + } + + public function testItCanNormalize() + { + $normalizer = new MessageNormalizer(); + + $payload = $normalizer->normalize(Message::ofUser('Hello World')); + + $this->assertArrayHasKey('id', $payload); + $this->assertArrayHasKey('type', $payload); + $this->assertArrayHasKey('content', $payload); + $this->assertArrayHasKey('contentAsBase64', $payload); + $this->assertArrayHasKey('toolsCalls', $payload); + $this->assertArrayHasKey('metadata', $payload); + $this->assertArrayHasKey('addedAt', $payload); + } + + public function testItCanDenormalize() + { + $normalizer = new MessageNormalizer(); + + $message = $normalizer->denormalize([ + 'id' => Uuid::v7()->toRfc4122(), + 'type' => UserMessage::class, + 'content' => '', + 'contentAsBase64' => [ + [ + 'type' => Text::class, + 'content' => 'What is the Symfony framework?', + ], + ], + 'toolsCalls' => [], + 'metadata' => [], + 'addedAt' => (new \DateTimeImmutable())->getTimestamp(), + ], MessageInterface::class); + + $this->assertSame(Role::User, $message->getRole()); + $this->assertArrayHasKey('addedAt', $message->getMetadata()->all()); + } +}