refactor: rework LLM Chain into Symfony AI

This commit is contained in:
Christopher Hertel
2025-06-06 23:20:12 +02:00
commit c9c15893a5
18 changed files with 657 additions and 0 deletions

6
.gitattributes vendored Normal file
View File

@@ -0,0 +1,6 @@
/.github export-ignore
/tests export-ignore
.gitattributes export-ignore
.gitignore export-ignore
phpstan.dist.neon export-ignore
phpunit.xml.dist export-ignore

19
LICENSE Normal file
View File

@@ -0,0 +1,19 @@
Copyright (c) 2025-present Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

70
composer.json Normal file
View File

@@ -0,0 +1,70 @@
{
"name": "symfony/ai-store",
"type": "library",
"description": "PHP library for abstracting interaction with data stores in AI applications.",
"keywords": [
"ai",
"mongodb",
"pinecone",
"chromadb"
],
"license": "MIT",
"authors": [
{
"name": "Christopher Hertel",
"email": "mail@christopher-hertel.de"
},
{
"name": "Oskar Stark",
"email": "oskarstark@googlemail.com"
}
],
"require": {
"php": ">=8.2",
"ext-fileinfo": "*",
"psr/log": "^3.0",
"symfony/ai-platform": "@dev",
"symfony/clock": "^6.4 || ^7.1",
"symfony/http-client": "^6.4 || ^7.1",
"symfony/uid": "^6.4 || ^7.1",
"webmozart/assert": "^1.11"
},
"conflict": {
"mongodb/mongodb": "<1.21"
},
"require-dev": {
"codewithkyrian/chromadb-php": "^0.2.1 || ^0.3 || ^0.4",
"mongodb/mongodb": "^1.21",
"phpstan/phpstan": "^2.0",
"phpstan/phpstan-webmozart-assert": "^2.0",
"phpunit/phpunit": "^11.5",
"probots-io/pinecone-php": "^1.0"
},
"suggest": {
"codewithkyrian/chromadb-php": "For using the ChromaDB as retrieval vector store.",
"mongodb/mongodb": "For using MongoDB Atlas as retrieval vector store.",
"probots-io/pinecone-php": "For using the Pinecone as retrieval vector store."
},
"config": {
"sort-packages": true
},
"extra": {
"thanks": {
"name": "symfony/ai",
"url": "https://github.com/symfony/ai"
}
},
"autoload": {
"psr-4": {
"Symfony\\AI\\Store\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Symfony\\AI\\Store\\Tests\\": "tests/"
}
},
"repositories": [
{"type": "path", "url": "../platform"}
]
}

6
phpstan.dist.neon Normal file
View File

@@ -0,0 +1,6 @@
parameters:
level: 6
paths:
- src/
- tests/
treatPhpDocTypesAsCertain: false

24
phpunit.xml.dist Normal file
View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.5/phpunit.xsd"
bootstrap="vendor/autoload.php"
cacheDirectory=".phpunit.cache"
colors="true"
executionOrder="depends,defects"
requireCoverageMetadata="true"
beStrictAboutCoverageMetadata="true"
beStrictAboutOutputDuringTests="true"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<testsuite name="default">
<directory>tests</directory>
</testsuite>
</testsuites>
<source ignoreIndirectDeprecations="true" restrictNotices="true" restrictWarnings="true">
<include>
<directory>src</directory>
</include>
</source>
</phpunit>

21
src/Document/Metadata.php Normal file
View File

@@ -0,0 +1,21 @@
<?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\Store\Document;
/**
* @template-extends \ArrayObject<string, mixed>
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final class Metadata extends \ArrayObject
{
}

View File

@@ -0,0 +1,29 @@
<?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\Store\Document;
use Symfony\Component\Uid\Uuid;
use Webmozart\Assert\Assert;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final readonly class TextDocument
{
public function __construct(
public Uuid $id,
public string $content,
public Metadata $metadata = new Metadata(),
) {
Assert::stringNotEmpty(trim($this->content));
}
}

View File

@@ -0,0 +1,29 @@
<?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\Store\Document;
use Symfony\AI\Platform\Vector\VectorInterface;
use Symfony\Component\Uid\Uuid;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final readonly class VectorDocument
{
public function __construct(
public Uuid $id,
public VectorInterface $vector,
public Metadata $metadata = new Metadata(),
public ?float $score = null,
) {
}
}

97
src/Embedder.php Normal file
View File

@@ -0,0 +1,97 @@
<?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\Store;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Symfony\AI\Platform\Capability;
use Symfony\AI\Platform\Model;
use Symfony\AI\Platform\PlatformInterface;
use Symfony\AI\Store\Document\TextDocument;
use Symfony\AI\Store\Document\VectorDocument;
use Symfony\Component\Clock\Clock;
use Symfony\Component\Clock\ClockInterface;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final readonly class Embedder
{
private ClockInterface $clock;
public function __construct(
private PlatformInterface $platform,
private Model $model,
private StoreInterface $store,
?ClockInterface $clock = null,
private LoggerInterface $logger = new NullLogger(),
) {
$this->clock = $clock ?? Clock::get();
}
/**
* @param TextDocument|TextDocument[] $documents
*/
public function embed(TextDocument|array $documents, int $chunkSize = 0, int $sleep = 0): void
{
if ($documents instanceof TextDocument) {
$documents = [$documents];
}
if ([] === $documents) {
$this->logger->debug('No documents to embed');
return;
}
$chunks = 0 !== $chunkSize ? array_chunk($documents, $chunkSize) : [$documents];
foreach ($chunks as $chunk) {
$this->store->add(...$this->createVectorDocuments($chunk));
if (0 !== $sleep) {
$this->clock->sleep($sleep);
}
}
}
/**
* @param TextDocument[] $documents
*
* @return VectorDocument[]
*/
private function createVectorDocuments(array $documents): array
{
if ($this->model->supports(Capability::INPUT_MULTIPLE)) {
$response = $this->platform->request($this->model, array_map(fn (TextDocument $document) => $document->content, $documents));
$vectors = $response->getContent();
} else {
$responses = [];
foreach ($documents as $document) {
$responses[] = $this->platform->request($this->model, $document->content);
}
$vectors = [];
foreach ($responses as $response) {
$vectors = array_merge($vectors, $response->getContent());
}
}
$vectorDocuments = [];
foreach ($documents as $i => $document) {
$vectorDocuments[] = new VectorDocument($document->id, $vectors[$i], $document->metadata);
}
return $vectorDocuments;
}
}

View File

@@ -0,0 +1,19 @@
<?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\Store\Exception;
/**
* @author Oskar Stark <oskarstark@googlemail.com>
*/
interface ExceptionInterface extends \Throwable
{
}

View File

@@ -0,0 +1,19 @@
<?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\Store\Exception;
/**
* @author Oskar Stark <oskarstark@googlemail.com>
*/
class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface
{
}

View File

@@ -0,0 +1,19 @@
<?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\Store\Exception;
/**
* @author Oskar Stark <oskarstark@googlemail.com>
*/
class RuntimeException extends \RuntimeException implements ExceptionInterface
{
}

View File

@@ -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\Store;
/**
* @author Oskar Stark <oskarstark@googlemail.com>
*/
interface InitializableStoreInterface extends StoreInterface
{
/**
* @param array<mixed> $options
*/
public function initialize(array $options = []): void;
}

22
src/StoreInterface.php Normal file
View File

@@ -0,0 +1,22 @@
<?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\Store;
use Symfony\AI\Store\Document\VectorDocument;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
interface StoreInterface
{
public function add(VectorDocument ...$documents): void;
}

View File

@@ -0,0 +1,28 @@
<?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\Store;
use Symfony\AI\Platform\Vector\Vector;
use Symfony\AI\Store\Document\VectorDocument;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
interface VectorStoreInterface extends StoreInterface
{
/**
* @param array<string, mixed> $options
*
* @return VectorDocument[]
*/
public function query(Vector $vector, array $options = [], ?float $minScore = null): array;
}

View File

@@ -0,0 +1,57 @@
<?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\Store\Tests\Double;
use Symfony\AI\Platform\Model;
use Symfony\AI\Platform\ModelClientInterface;
use Symfony\AI\Platform\Platform;
use Symfony\AI\Platform\Response\ResponseInterface;
use Symfony\AI\Platform\Response\ResponseInterface as LlmResponse;
use Symfony\AI\Platform\Response\VectorResponse;
use Symfony\AI\Platform\ResponseConverterInterface;
use Symfony\AI\Platform\Vector\Vector;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse;
final class PlatformTestHandler implements ModelClientInterface, ResponseConverterInterface
{
public int $createCalls = 0;
public function __construct(
private readonly ?ResponseInterface $create = null,
) {
}
public static function createPlatform(?ResponseInterface $create = null): Platform
{
$handler = new self($create);
return new Platform([$handler], [$handler]);
}
public function supports(Model $model): bool
{
return true;
}
public function request(Model $model, array|string|object $payload, array $options = []): HttpResponse
{
++$this->createCalls;
return new MockResponse();
}
public function convert(HttpResponse $response, array $options = []): LlmResponse
{
return $this->create ?? new VectorResponse(new Vector([1, 2, 3]));
}
}

View File

@@ -0,0 +1,31 @@
<?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\Store\Tests\Double;
use Symfony\AI\Store\Document\VectorDocument;
use Symfony\AI\Store\StoreInterface;
final class TestStore implements StoreInterface
{
/**
* @var VectorDocument[]
*/
public array $documents = [];
public int $addCalls = 0;
public function add(VectorDocument ...$documents): void
{
++$this->addCalls;
$this->documents = array_merge($this->documents, $documents);
}
}

138
tests/EmbedderTest.php Normal file
View File

@@ -0,0 +1,138 @@
<?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\Store\Tests;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Medium;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\UsesClass;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\AI\Platform\Bridge\OpenAI\Embeddings;
use Symfony\AI\Platform\Message\ToolCallMessage;
use Symfony\AI\Platform\Platform;
use Symfony\AI\Platform\Response\AsyncResponse;
use Symfony\AI\Platform\Response\ToolCall;
use Symfony\AI\Platform\Response\VectorResponse;
use Symfony\AI\Platform\Vector\Vector;
use Symfony\AI\Store\Document\Metadata;
use Symfony\AI\Store\Document\TextDocument;
use Symfony\AI\Store\Document\VectorDocument;
use Symfony\AI\Store\Embedder;
use Symfony\AI\Store\Tests\Double\PlatformTestHandler;
use Symfony\AI\Store\Tests\Double\TestStore;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\Uid\Uuid;
#[CoversClass(Embedder::class)]
#[Medium]
#[UsesClass(TextDocument::class)]
#[UsesClass(Vector::class)]
#[UsesClass(VectorDocument::class)]
#[UsesClass(ToolCallMessage::class)]
#[UsesClass(ToolCall::class)]
#[UsesClass(Embeddings::class)]
#[UsesClass(Platform::class)]
#[UsesClass(AsyncResponse::class)]
#[UsesClass(VectorResponse::class)]
final class EmbedderTest extends TestCase
{
#[Test]
public function embedSingleDocument(): void
{
$document = new TextDocument($id = Uuid::v4(), 'Test content');
$vector = new Vector([0.1, 0.2, 0.3]);
$embedder = new Embedder(
PlatformTestHandler::createPlatform(new VectorResponse($vector)),
new Embeddings(),
$store = new TestStore(),
new MockClock(),
);
$embedder->embed($document);
self::assertCount(1, $store->documents);
self::assertInstanceOf(VectorDocument::class, $store->documents[0]);
self::assertSame($id, $store->documents[0]->id);
self::assertSame($vector, $store->documents[0]->vector);
}
#[Test]
public function embedEmptyDocumentList(): void
{
$logger = self::createMock(LoggerInterface::class);
$logger->expects(self::once())->method('debug')->with('No documents to embed');
$embedder = new Embedder(
PlatformTestHandler::createPlatform(),
new Embeddings(),
$store = new TestStore(),
new MockClock(),
$logger,
);
$embedder->embed([]);
self::assertSame([], $store->documents);
}
#[Test]
public function embedDocumentWithMetadata(): void
{
$metadata = new Metadata(['key' => 'value']);
$document = new TextDocument($id = Uuid::v4(), 'Test content', $metadata);
$vector = new Vector([0.1, 0.2, 0.3]);
$embedder = new Embedder(
PlatformTestHandler::createPlatform(new VectorResponse($vector)),
new Embeddings(),
$store = new TestStore(),
new MockClock(),
);
$embedder->embed($document);
self::assertSame(1, $store->addCalls);
self::assertCount(1, $store->documents);
self::assertInstanceOf(VectorDocument::class, $store->documents[0]);
self::assertSame($id, $store->documents[0]->id);
self::assertSame($vector, $store->documents[0]->vector);
self::assertSame(['key' => 'value'], $store->documents[0]->metadata->getArrayCopy());
}
#[Test]
public function embedWithSleep(): void
{
$vector1 = new Vector([0.1, 0.2, 0.3]);
$vector2 = new Vector([0.4, 0.5, 0.6]);
$document1 = new TextDocument(Uuid::v4(), 'Test content 1');
$document2 = new TextDocument(Uuid::v4(), 'Test content 2');
$embedder = new Embedder(
PlatformTestHandler::createPlatform(new VectorResponse($vector1, $vector2)),
new Embeddings(),
$store = new TestStore(),
$clock = new MockClock('2024-01-01 00:00:00'),
);
$embedder->embed(
documents: [$document1, $document2],
sleep: 3
);
self::assertSame(1, $store->addCalls);
self::assertCount(2, $store->documents);
self::assertSame('2024-01-01 00:00:03', $clock->now()->format('Y-m-d H:i:s'));
}
}