Skip to content
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
4 changes: 4 additions & 0 deletions docs/components/chat.rst
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ with a ``Symfony\AI\Agent\AgentInterface`` and a ``Symfony\AI\Chat\MessageStoreI
You can find more advanced usage in combination with an Agent using the store for long-term context:

* `External services storage with Cache`_
* `Long-term context with Doctrine DBAL`_
* `Current session context storage with HttpFoundation session`_
* `Current process context storage with InMemory`_
* `Long-term context with Meilisearch`_
Expand All @@ -45,6 +46,7 @@ Supported Message stores
------------------------

* `Cache`_
* `Doctrine DBAL`_
* `HttpFoundation session`_
* `InMemory`_
* `Meilisearch`_
Expand Down Expand Up @@ -127,13 +129,15 @@ store and ``bin/console ai:message-store:drop`` to clean up the message store:
$ php bin/console ai:message-store:drop symfonycon

.. _`External services storage with Cache`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-cache.php
.. _`Long-term context with Doctrine DBAL`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-doctrine-dbal.php
.. _`Current session context storage with HttpFoundation session`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-session.php
.. _`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
.. _`Long-term context with SurrealDb`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-surrealdb.php
.. _`Cache`: https://symfony.com/doc/current/components/cache.html
.. _`Doctrine DBAL`: https://www.doctrine-project.org/projects/dbal.html
.. _`InMemory`: https://www.php.net/manual/en/language.types.array.php
.. _`HttpFoundation session`: https://developers.cloudflare.com/vectorize/
.. _`Meilisearch`: https://www.meilisearch.com/
Expand Down
40 changes: 40 additions & 0 deletions examples/chat/persistent-chat-doctrine-dbal.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?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 Doctrine\DBAL\DriverManager;
use Symfony\AI\Agent\Agent;
use Symfony\AI\Chat\Bridge\Doctrine\DoctrineDbalMessageStore;
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());

$connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]);

$store = new DoctrineDbalMessageStore('symfony', $connection);
$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;
6 changes: 6 additions & 0 deletions examples/commands/message-stores.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

require_once dirname(__DIR__).'/bootstrap.php';

use Doctrine\DBAL\DriverManager;
use Symfony\AI\Chat\Bridge\Doctrine\DoctrineDbalMessageStore;
use Symfony\AI\Chat\Bridge\HttpFoundation\SessionStore;
use Symfony\AI\Chat\Bridge\Local\CacheStore;
use Symfony\AI\Chat\Bridge\Local\InMemoryStore;
Expand All @@ -37,6 +39,10 @@

$factories = [
'cache' => static fn (): CacheStore => new CacheStore(new ArrayAdapter(), cacheKey: 'symfony'),
'doctrine' => static fn (): DoctrineDbalMessageStore => new DoctrineDbalMessageStore(
'symfony',
DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]),
),
'meilisearch' => static fn (): MeilisearchMessageStore => new MeilisearchMessageStore(
http_client(),
env('MEILISEARCH_HOST'),
Expand Down
15 changes: 15 additions & 0 deletions src/ai-bundle/config/options.php
Original file line number Diff line number Diff line change
Expand Up @@ -806,6 +806,21 @@
->end()
->end()
->end()
->arrayNode('doctrine')
->children()
->arrayNode('dbal')
->useAttributeAsKey('name')
->arrayPrototype()
->children()
->stringNode('connection')->cannotBeEmpty()->end()
->stringNode('table_name')
->info('The name of the message store will be used if the table_name is not set')
->end()
->end()
->end()
->end()
->end()
->end()
->arrayNode('meilisearch')
->useAttributeAsKey('name')
->arrayPrototype()
Expand Down
21 changes: 21 additions & 0 deletions src/ai-bundle/src/AiBundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
use Symfony\AI\AiBundle\Profiler\TraceablePlatform;
use Symfony\AI\AiBundle\Profiler\TraceableToolbox;
use Symfony\AI\AiBundle\Security\Attribute\IsGrantedTool;
use Symfony\AI\Chat\Bridge\Doctrine\DoctrineDbalMessageStore;
use Symfony\AI\Chat\Bridge\HttpFoundation\SessionStore;
use Symfony\AI\Chat\Bridge\Local\CacheStore as CacheMessageStore;
use Symfony\AI\Chat\Bridge\Meilisearch\MessageStore as MeilisearchMessageStore;
Expand Down Expand Up @@ -1535,6 +1536,26 @@ private function processMessageStoreConfig(string $type, array $messageStores, C
}
}

if ('doctrine' === $type) {
foreach ($messageStores['dbal'] ?? [] as $name => $dbalMessageStore) {
$definition = new Definition(DoctrineDbalMessageStore::class);
$definition
->setLazy(true)
->setArguments([
$dbalMessageStore['connection'],
$dbalMessageStore['table_name'] ?? $name,
new Reference(\sprintf('doctrine.dbal.%s_connection', $dbalMessageStore['connection'])),
new Reference('serializer'),
])
->addTag('proxy', ['interface' => MessageStoreInterface::class])
->addTag('ai.message_store');

$container->setDefinition('ai.message_store.'.$type.'.dbal.'.$name, $definition);
$container->registerAliasForArgument('ai.message_store.'.$type.'.'.$name, MessageStoreInterface::class, $name);
$container->registerAliasForArgument('ai.message_store.'.$type.'.'.$name, MessageStoreInterface::class, $type.'_'.$name);
}
}

if ('meilisearch' === $type) {
foreach ($messageStores as $name => $messageStore) {
$definition = new Definition(MeilisearchMessageStore::class);
Expand Down
69 changes: 69 additions & 0 deletions src/ai-bundle/tests/DependencyInjection/AiBundleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2942,6 +2942,67 @@ public function testCacheMessageStoreCanBeConfiguredWithCustomTtl()
$this->assertTrue($cacheMessageStoreDefinition->hasTag('ai.message_store'));
}

public function testDoctrineDbalMessageStoreCanBeConfiguredWithCustomKey()
{
$container = $this->buildContainer([
'ai' => [
'message_store' => [
'doctrine' => [
'dbal' => [
'default' => [
'connection' => 'default',
],
],
],
],
],
]);

$doctrineDbalDefaultMessageStoreDefinition = $container->getDefinition('ai.message_store.doctrine.dbal.default');

$this->assertSame('default', (string) $doctrineDbalDefaultMessageStoreDefinition->getArgument(0));
$this->assertSame('default', (string) $doctrineDbalDefaultMessageStoreDefinition->getArgument(1));
$this->assertInstanceOf(Reference::class, $doctrineDbalDefaultMessageStoreDefinition->getArgument(2));
$this->assertSame('doctrine.dbal.default_connection', (string) $doctrineDbalDefaultMessageStoreDefinition->getArgument(2));
$this->assertInstanceOf(Reference::class, $doctrineDbalDefaultMessageStoreDefinition->getArgument(3));
$this->assertSame('serializer', (string) $doctrineDbalDefaultMessageStoreDefinition->getArgument(3));

$this->assertTrue($doctrineDbalDefaultMessageStoreDefinition->hasTag('proxy'));
$this->assertSame([['interface' => MessageStoreInterface::class]], $doctrineDbalDefaultMessageStoreDefinition->getTag('proxy'));
$this->assertTrue($doctrineDbalDefaultMessageStoreDefinition->hasTag('ai.message_store'));
}

public function testDoctrineDbalMessageStoreWithCustomTableNameCanBeConfiguredWithCustomKey()
{
$container = $this->buildContainer([
'ai' => [
'message_store' => [
'doctrine' => [
'dbal' => [
'default' => [
'connection' => 'default',
'table_name' => 'foo',
],
],
],
],
],
]);

$doctrineDbalDefaultMessageStoreDefinition = $container->getDefinition('ai.message_store.doctrine.dbal.default');

$this->assertSame('default', (string) $doctrineDbalDefaultMessageStoreDefinition->getArgument(0));
$this->assertSame('foo', (string) $doctrineDbalDefaultMessageStoreDefinition->getArgument(1));
$this->assertInstanceOf(Reference::class, $doctrineDbalDefaultMessageStoreDefinition->getArgument(2));
$this->assertSame('doctrine.dbal.default_connection', (string) $doctrineDbalDefaultMessageStoreDefinition->getArgument(2));
$this->assertInstanceOf(Reference::class, $doctrineDbalDefaultMessageStoreDefinition->getArgument(3));
$this->assertSame('serializer', (string) $doctrineDbalDefaultMessageStoreDefinition->getArgument(3));

$this->assertTrue($doctrineDbalDefaultMessageStoreDefinition->hasTag('proxy'));
$this->assertSame([['interface' => MessageStoreInterface::class]], $doctrineDbalDefaultMessageStoreDefinition->getTag('proxy'));
$this->assertTrue($doctrineDbalDefaultMessageStoreDefinition->hasTag('ai.message_store'));
}

public function testMeilisearchMessageStoreIsConfigured()
{
$container = $this->buildContainer([
Expand Down Expand Up @@ -3597,6 +3658,14 @@ private function getFullConfig(): array
'key' => 'foo',
],
],
'doctrine' => [
'dbal' => [
'default' => [
'connection' => 'default',
'table_name' => 'foo',
],
],
],
'memory' => [
'my_memory_message_store' => [
'identifier' => '_memory',
Expand Down
1 change: 1 addition & 0 deletions src/chat/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
},
"require-dev": {
"ext-redis": "*",
"doctrine/dbal": "^3.3 || ^4.0",
"phpstan/phpstan": "^2.0",
"phpstan/phpstan-strict-rules": "^2.0",
"phpunit/phpunit": "^11.5.13",
Expand Down
148 changes: 148 additions & 0 deletions src/chat/src/Bridge/Doctrine/DoctrineDbalMessageStore.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
<?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\Chat\Bridge\Doctrine;

use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Connection as DBALConnection;
use Doctrine\DBAL\Platforms\OraclePlatform;
use Doctrine\DBAL\Result;
use Doctrine\DBAL\Schema\Name\Identifier;
use Doctrine\DBAL\Schema\Name\UnqualifiedName;
use Doctrine\DBAL\Schema\PrimaryKeyConstraint;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Symfony\AI\Chat\Exception\InvalidArgumentException;
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 <personal@guillaumeloulier.fr>
*/
final class DoctrineDbalMessageStore implements ManagedStoreInterface, MessageStoreInterface
{
public function __construct(
private readonly string $tableName,
private readonly DBALConnection $dbalConnection,
private readonly SerializerInterface $serializer = new Serializer([
new ArrayDenormalizer(),
new MessageNormalizer(),
], [new JsonEncoder()]),
) {
}

public function setup(array $options = []): void
{
if ([] !== $options) {
throw new InvalidArgumentException('No supported options.');
}

$schema = $this->dbalConnection->createSchemaManager()->introspectSchema();

if ($schema->hasTable($this->tableName)) {
return;
}

$this->addTableToSchema($schema);
}

public function drop(): void
{
$schema = $this->dbalConnection->createSchemaManager()->introspectSchema();

if (!$schema->hasTable($this->tableName)) {
return;
}

$queryBuilder = $this->dbalConnection->createQueryBuilder()
->delete($this->tableName);

$this->dbalConnection->transactional(fn (Connection $connection): Result => $connection->executeQuery(
$queryBuilder->getSQL(),
));
}

public function save(MessageBag $messages): void
{
$queryBuilder = $this->dbalConnection->createQueryBuilder()
->insert($this->tableName)
->values([
'messages' => '?',
]);

$this->dbalConnection->transactional(fn (Connection $connection): Result => $connection->executeQuery(
$queryBuilder->getSQL(),
[
$this->serializer->serialize($messages->getMessages(), 'json'),
],
$queryBuilder->getParameterTypes(),
));
}

public function load(): MessageBag
{
$queryBuilder = $this->dbalConnection->createQueryBuilder()
->select('messages')
->from($this->tableName)
;

$result = $this->dbalConnection->transactional(static fn (Connection $connection): Result => $connection->executeQuery(
$queryBuilder->getSQL(),
));

$messages = array_map(
fn (array $payload): array => $this->serializer->deserialize($payload['messages'], MessageInterface::class.'[]', 'json'),
$result->fetchAllAssociative(),
);

return new MessageBag(...array_merge(...$messages));
}

private function addTableToSchema(Schema $schema): void
{
$table = $schema->createTable($this->tableName);
$table->addOption('_symfony_ai_chat_table_name', $this->tableName);
$idColumn = $table->addColumn('id', Types::BIGINT)
->setAutoincrement(true)
->setNotnull(true);
$table->addColumn('messages', Types::TEXT)
->setNotnull(true);
if (class_exists(PrimaryKeyConstraint::class)) {
$table->addPrimaryKeyConstraint(new PrimaryKeyConstraint(null, [
new UnqualifiedName(Identifier::unquoted('id')),
], true));
} else {
$table->setPrimaryKey(['id']);
}

// We need to create a sequence for Oracle and set the id column to get the correct nextval
if ($this->dbalConnection->getDatabasePlatform() instanceof OraclePlatform) {
$serverVersion = $this->dbalConnection->executeQuery("SELECT version FROM product_component_version WHERE product LIKE 'Oracle Database%'")->fetchOne();
if (version_compare($serverVersion, '12.1.0', '>=')) {
$idColumn->setAutoincrement(false); // disable the creation of SEQUENCE and TRIGGER
$idColumn->setDefault($this->tableName.'_seq.nextval');

$schema->createSequence($this->tableName.'_seq');
}
}

foreach ($schema->toSql($this->dbalConnection->getDatabasePlatform()) as $sql) {
$this->dbalConnection->executeQuery($sql);
}
}
}
Loading
Loading