diff --git a/examples/composer.json b/examples/composer.json index 66e09d1b..30169cb1 100644 --- a/examples/composer.json +++ b/examples/composer.json @@ -22,6 +22,7 @@ "symfony/event-dispatcher": "^6.4|^7.0", "symfony/filesystem": "^6.4|^7.0", "symfony/finder": "^6.4|^7.0", + "symfony/json-path": "7.3.*", "symfony/process": "^6.4|^7.0", "symfony/var-dumper": "^6.4|^7.0" }, diff --git a/src/platform/composer.json b/src/platform/composer.json index d8d3b622..b61a21d3 100644 --- a/src/platform/composer.json +++ b/src/platform/composer.json @@ -28,6 +28,7 @@ "psr/log": "^3.0", "symfony/clock": "^6.4 || ^7.1", "symfony/http-client": "^6.4 || ^7.1", + "symfony/json-path": "7.3.*", "symfony/property-access": "^6.4 || ^7.1", "symfony/property-info": "^6.4 || ^7.1", "symfony/serializer": "^6.4 || ^7.1", diff --git a/src/platform/src/Bridge/Albert/PlatformFactory.php b/src/platform/src/Bridge/Albert/PlatformFactory.php index 0146714e..a99268a7 100644 --- a/src/platform/src/Bridge/Albert/PlatformFactory.php +++ b/src/platform/src/Bridge/Albert/PlatformFactory.php @@ -11,7 +11,6 @@ namespace Symfony\AI\Platform\Bridge\Albert; -use Symfony\AI\Platform\Bridge\OpenAI\Embeddings; use Symfony\AI\Platform\Bridge\OpenAI\GPT; use Symfony\AI\Platform\Contract; use Symfony\AI\Platform\Exception\InvalidArgumentException; @@ -40,7 +39,7 @@ public static function create( new GPTModelClient($httpClient, $apiKey, $baseUrl), new EmbeddingsModelClient($httpClient, $apiKey, $baseUrl), ], - [new GPT\ResultConverter(), new Embeddings\ResultConverter()], + [new GPT\ResultConverter()], Contract::create(), ); } diff --git a/src/platform/src/Bridge/Azure/OpenAI/PlatformFactory.php b/src/platform/src/Bridge/Azure/OpenAI/PlatformFactory.php index 8b3299ba..2e954b5e 100644 --- a/src/platform/src/Bridge/Azure/OpenAI/PlatformFactory.php +++ b/src/platform/src/Bridge/Azure/OpenAI/PlatformFactory.php @@ -41,7 +41,7 @@ public static function create( return new Platform( [$GPTModelClient, $embeddingsModelClient, $whisperModelClient], - [new GPT\ResultConverter(), new Embeddings\ResultConverter(), new Whisper\ResultConverter()], + [new GPT\ResultConverter(), new Whisper\ResultConverter()], $contract ?? Contract::create(new AudioNormalizer()), ); } diff --git a/src/platform/src/Bridge/Google/Embeddings.php b/src/platform/src/Bridge/Google/Embeddings.php index ae6a17ff..7a4dcac4 100644 --- a/src/platform/src/Bridge/Google/Embeddings.php +++ b/src/platform/src/Bridge/Google/Embeddings.php @@ -32,6 +32,6 @@ class Embeddings extends Model */ public function __construct(string $name = self::GEMINI_EMBEDDING_EXP_03_07, array $options = []) { - parent::__construct($name, [Capability::INPUT_MULTIPLE], $options); + parent::__construct($name, [Capability::INPUT_MULTIPLE, Capability::OUTPUT_VECTOR], $options); } } diff --git a/src/platform/src/Bridge/Google/Embeddings/ResultConverter.php b/src/platform/src/Bridge/Google/Embeddings/ResultConverter.php index 5ba44de3..0ee02b45 100644 --- a/src/platform/src/Bridge/Google/Embeddings/ResultConverter.php +++ b/src/platform/src/Bridge/Google/Embeddings/ResultConverter.php @@ -11,37 +11,15 @@ namespace Symfony\AI\Platform\Bridge\Google\Embeddings; -use Symfony\AI\Platform\Bridge\Google\Embeddings; -use Symfony\AI\Platform\Exception\RuntimeException; -use Symfony\AI\Platform\Model; -use Symfony\AI\Platform\Result\RawResultInterface; -use Symfony\AI\Platform\Result\VectorResult; -use Symfony\AI\Platform\ResultConverterInterface; -use Symfony\AI\Platform\Vector\Vector; +use Symfony\AI\Platform\Contract\ResultConverter\VectorResultConverter; /** * @author Valtteri R */ -final readonly class ResultConverter implements ResultConverterInterface +final readonly class ResultConverter extends VectorResultConverter { - public function supports(Model $model): bool + public function __construct() { - return $model instanceof Embeddings; - } - - public function convert(RawResultInterface $result, array $options = []): VectorResult - { - $data = $result->getData(); - - if (!isset($data['embeddings'])) { - throw new RuntimeException('Response does not contain data'); - } - - return new VectorResult( - ...array_map( - static fn (array $item): Vector => new Vector($item['values']), - $data['embeddings'], - ), - ); + parent::__construct('$.embeddings[*].values'); } } diff --git a/src/platform/src/Bridge/Mistral/Embeddings.php b/src/platform/src/Bridge/Mistral/Embeddings.php index 6f96f5f4..b4b7c1b1 100644 --- a/src/platform/src/Bridge/Mistral/Embeddings.php +++ b/src/platform/src/Bridge/Mistral/Embeddings.php @@ -28,6 +28,6 @@ public function __construct( string $name = self::MISTRAL_EMBED, array $options = [], ) { - parent::__construct($name, [Capability::INPUT_MULTIPLE], $options); + parent::__construct($name, [Capability::INPUT_MULTIPLE, Capability::OUTPUT_VECTOR], $options); } } diff --git a/src/platform/src/Bridge/Mistral/PlatformFactory.php b/src/platform/src/Bridge/Mistral/PlatformFactory.php index f17e4a6d..78aa34f9 100644 --- a/src/platform/src/Bridge/Mistral/PlatformFactory.php +++ b/src/platform/src/Bridge/Mistral/PlatformFactory.php @@ -32,7 +32,7 @@ public static function create( return new Platform( [new Embeddings\ModelClient($httpClient, $apiKey), new Llm\ModelClient($httpClient, $apiKey)], - [new Embeddings\ResultConverter(), new Llm\ResultConverter()], + [new Llm\ResultConverter()], $contract ?? Contract::create(new ToolNormalizer()), ); } diff --git a/src/platform/src/Bridge/OpenAI/Embeddings.php b/src/platform/src/Bridge/OpenAI/Embeddings.php index 907aa897..94fdb7d5 100644 --- a/src/platform/src/Bridge/OpenAI/Embeddings.php +++ b/src/platform/src/Bridge/OpenAI/Embeddings.php @@ -11,6 +11,7 @@ namespace Symfony\AI\Platform\Bridge\OpenAI; +use Symfony\AI\Platform\Capability; use Symfony\AI\Platform\Model; /** @@ -27,6 +28,6 @@ class Embeddings extends Model */ public function __construct(string $name = self::TEXT_3_SMALL, array $options = []) { - parent::__construct($name, [], $options); + parent::__construct($name, [Capability::OUTPUT_VECTOR], $options); } } diff --git a/src/platform/src/Bridge/OpenAI/Embeddings/ResultConverter.php b/src/platform/src/Bridge/OpenAI/Embeddings/ResultConverter.php deleted file mode 100644 index d446de79..00000000 --- a/src/platform/src/Bridge/OpenAI/Embeddings/ResultConverter.php +++ /dev/null @@ -1,47 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\Platform\Bridge\OpenAI\Embeddings; - -use Symfony\AI\Platform\Bridge\OpenAI\Embeddings; -use Symfony\AI\Platform\Exception\RuntimeException; -use Symfony\AI\Platform\Model; -use Symfony\AI\Platform\Result\RawResultInterface; -use Symfony\AI\Platform\Result\VectorResult; -use Symfony\AI\Platform\ResultConverterInterface; -use Symfony\AI\Platform\Vector\Vector; - -/** - * @author Christopher Hertel - */ -final class ResultConverter implements ResultConverterInterface -{ - public function supports(Model $model): bool - { - return $model instanceof Embeddings; - } - - public function convert(RawResultInterface $result, array $options = []): VectorResult - { - $data = $result->getData(); - - if (!isset($data['data'])) { - throw new RuntimeException('Response does not contain data'); - } - - return new VectorResult( - ...array_map( - static fn (array $item): Vector => new Vector($item['embedding']), - $data['data'] - ), - ); - } -} diff --git a/src/platform/src/Bridge/OpenAI/PlatformFactory.php b/src/platform/src/Bridge/OpenAI/PlatformFactory.php index 62c09d1b..e1a520e5 100644 --- a/src/platform/src/Bridge/OpenAI/PlatformFactory.php +++ b/src/platform/src/Bridge/OpenAI/PlatformFactory.php @@ -41,7 +41,6 @@ public static function create( ], [ new GPT\ResultConverter(), - new Embeddings\ResultConverter(), new DallE\ResultConverter(), new WhisperResponseConverter(), ], diff --git a/src/platform/src/Bridge/Voyage/PlatformFactory.php b/src/platform/src/Bridge/Voyage/PlatformFactory.php index cb82e43f..27294d52 100644 --- a/src/platform/src/Bridge/Voyage/PlatformFactory.php +++ b/src/platform/src/Bridge/Voyage/PlatformFactory.php @@ -29,6 +29,6 @@ public static function create( ): Platform { $httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); - return new Platform([new ModelClient($httpClient, $apiKey)], [new ResultConverter()], $contract); + return new Platform([new ModelClient($httpClient, $apiKey)], [], $contract); } } diff --git a/src/platform/src/Bridge/Voyage/ResultConverter.php b/src/platform/src/Bridge/Voyage/ResultConverter.php deleted file mode 100644 index 60a26d17..00000000 --- a/src/platform/src/Bridge/Voyage/ResultConverter.php +++ /dev/null @@ -1,44 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\Platform\Bridge\Voyage; - -use Symfony\AI\Platform\Exception\RuntimeException; -use Symfony\AI\Platform\Model; -use Symfony\AI\Platform\Result\RawResultInterface; -use Symfony\AI\Platform\Result\ResultInterface; -use Symfony\AI\Platform\Result\VectorResult; -use Symfony\AI\Platform\ResultConverterInterface; -use Symfony\AI\Platform\Vector\Vector; - -/** - * @author Christopher Hertel - */ -final readonly class ResultConverter implements ResultConverterInterface -{ - public function supports(Model $model): bool - { - return $model instanceof Voyage; - } - - public function convert(RawResultInterface $result, array $options = []): ResultInterface - { - $result = $result->getData(); - - if (!isset($result['data'])) { - throw new RuntimeException('Response does not contain embedding data'); - } - - $vectors = array_map(fn (array $data) => new Vector($data['embedding']), $result['data']); - - return new VectorResult($vectors[0]); - } -} diff --git a/src/platform/src/Bridge/Voyage/Voyage.php b/src/platform/src/Bridge/Voyage/Voyage.php index 95574849..f5e53912 100644 --- a/src/platform/src/Bridge/Voyage/Voyage.php +++ b/src/platform/src/Bridge/Voyage/Voyage.php @@ -31,6 +31,6 @@ class Voyage extends Model */ public function __construct(string $name = self::V3, array $options = []) { - parent::__construct($name, [Capability::INPUT_MULTIPLE], $options); + parent::__construct($name, [Capability::INPUT_MULTIPLE, Capability::OUTPUT_VECTOR], $options); } } diff --git a/src/platform/src/Capability.php b/src/platform/src/Capability.php index 70784590..983465dc 100644 --- a/src/platform/src/Capability.php +++ b/src/platform/src/Capability.php @@ -30,6 +30,7 @@ enum Capability: string case OUTPUT_STREAMING = 'output-streaming'; case OUTPUT_STRUCTURED = 'output-structured'; case OUTPUT_TEXT = 'output-text'; + case OUTPUT_VECTOR = 'output-vector'; // FUNCTIONALITY case TOOL_CALLING = 'tool-calling'; diff --git a/src/platform/src/Bridge/Mistral/Embeddings/ResultConverter.php b/src/platform/src/Contract/ResultConverter/VectorResultConverter.php similarity index 50% rename from src/platform/src/Bridge/Mistral/Embeddings/ResultConverter.php rename to src/platform/src/Contract/ResultConverter/VectorResultConverter.php index 32710f67..8c6e2ae9 100644 --- a/src/platform/src/Bridge/Mistral/Embeddings/ResultConverter.php +++ b/src/platform/src/Contract/ResultConverter/VectorResultConverter.php @@ -9,46 +9,44 @@ * file that was distributed with this source code. */ -namespace Symfony\AI\Platform\Bridge\Mistral\Embeddings; +namespace Symfony\AI\Platform\Contract\ResultConverter; -use Symfony\AI\Platform\Bridge\Mistral\Embeddings; +use Symfony\AI\Platform\Capability; use Symfony\AI\Platform\Exception\RuntimeException; use Symfony\AI\Platform\Model; use Symfony\AI\Platform\Result\RawHttpResult; use Symfony\AI\Platform\Result\RawResultInterface; +use Symfony\AI\Platform\Result\ResultInterface; use Symfony\AI\Platform\Result\VectorResult; use Symfony\AI\Platform\ResultConverterInterface; use Symfony\AI\Platform\Vector\Vector; +use Symfony\Component\JsonPath\JsonCrawler; +use Symfony\Component\JsonPath\JsonPath; -/** - * @author Christopher Hertel - */ -final readonly class ResultConverter implements ResultConverterInterface +readonly class VectorResultConverter implements ResultConverterInterface { + public function __construct( + private string|JsonPath $query = '$.data[*].embedding', + ) { + } + public function supports(Model $model): bool { - return $model instanceof Embeddings; + // TODO: THIS IS NOT ENOUGH TO GET ACTIVATED + return $model->supports(Capability::OUTPUT_VECTOR); } - public function convert(RawResultInterface|RawHttpResult $result, array $options = []): VectorResult + public function convert(RawResultInterface|RawHttpResult $result, array $options = []): ResultInterface { - $httpResponse = $result->getObject(); - - if (200 !== $httpResponse->getStatusCode()) { - throw new RuntimeException(\sprintf('Unexpected response code %d: %s', $httpResponse->getStatusCode(), $httpResponse->getContent(false))); - } - - $data = $result->getData(); + $crawler = new JsonCrawler($result->getObject()->getContent(false)); + $vectors = $crawler->find($this->query); - if (!isset($data['data'])) { - throw new RuntimeException('Response does not contain data'); + if (empty($vectors)) { + throw new RuntimeException('Response does not contain any vectors'); } return new VectorResult( - ...array_map( - static fn (array $item): Vector => new Vector($item['embedding']), - $data['data'] - ), + ...array_map(static fn (array $vector): Vector => new Vector($vector), $vectors), ); } } diff --git a/src/platform/src/Platform.php b/src/platform/src/Platform.php index de5a16dc..15489387 100644 --- a/src/platform/src/Platform.php +++ b/src/platform/src/Platform.php @@ -11,6 +11,7 @@ namespace Symfony\AI\Platform; +use Symfony\AI\Platform\Contract\ResultConverter\VectorResultConverter; use Symfony\AI\Platform\Exception\RuntimeException; use Symfony\AI\Platform\Result\RawResultInterface; use Symfony\AI\Platform\Result\ResultPromise; @@ -41,7 +42,10 @@ public function __construct( ) { $this->contract = $contract ?? Contract::create(); $this->modelClients = $modelClients instanceof \Traversable ? iterator_to_array($modelClients) : $modelClients; - $this->resultConverters = $resultConverters instanceof \Traversable ? iterator_to_array($resultConverters) : $resultConverters; + $this->resultConverters = array_merge( + $resultConverters instanceof \Traversable ? iterator_to_array($resultConverters) : $resultConverters, + [new VectorResultConverter()], + ); } public function invoke(Model $model, array|string|object $input, array $options = []): ResultPromise diff --git a/src/platform/tests/Bridge/Google/Embeddings/ResponseConverterTest.php b/src/platform/tests/Bridge/Google/Embeddings/ResultConverterTest.php similarity index 86% rename from src/platform/tests/Bridge/Google/Embeddings/ResponseConverterTest.php rename to src/platform/tests/Bridge/Google/Embeddings/ResultConverterTest.php index 5f8ecf20..de3c7b0a 100644 --- a/src/platform/tests/Bridge/Google/Embeddings/ResponseConverterTest.php +++ b/src/platform/tests/Bridge/Google/Embeddings/ResultConverterTest.php @@ -28,17 +28,17 @@ #[UsesClass(Vector::class)] #[UsesClass(VectorResult::class)] #[UsesClass(Embeddings::class)] -final class ResponseConverterTest extends TestCase +final class ResultConverterTest extends TestCase { #[Test] public function itConvertsAResponseToAVectorResponse(): void { - $result = $this->createStub(ResponseInterface::class); - $result - ->method('toArray') - ->willReturn(json_decode($this->getEmbeddingStub(), true)); + $response = $this->createStub(ResponseInterface::class); + $response + ->method('getContent') + ->willReturn($this->getEmbeddingStub()); - $vectorResponse = (new ResultConverter())->convert(new RawHttpResult($result)); + $vectorResponse = (new ResultConverter())->convert(new RawHttpResult($response)); $convertedContent = $vectorResponse->getContent(); self::assertCount(2, $convertedContent); diff --git a/src/platform/tests/Bridge/OpenAI/Embeddings/ResponseConverterTest.php b/src/platform/tests/Bridge/OpenAI/Embeddings/ResponseConverterTest.php deleted file mode 100644 index cf5b04b5..00000000 --- a/src/platform/tests/Bridge/OpenAI/Embeddings/ResponseConverterTest.php +++ /dev/null @@ -1,68 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\Platform\Tests\Bridge\OpenAI\Embeddings; - -use PHPUnit\Framework\Attributes\CoversClass; -use PHPUnit\Framework\Attributes\Small; -use PHPUnit\Framework\Attributes\Test; -use PHPUnit\Framework\Attributes\UsesClass; -use PHPUnit\Framework\TestCase; -use Symfony\AI\Platform\Bridge\OpenAI\Embeddings\ResultConverter; -use Symfony\AI\Platform\Result\RawHttpResult; -use Symfony\AI\Platform\Result\VectorResult; -use Symfony\AI\Platform\Vector\Vector; -use Symfony\Contracts\HttpClient\ResponseInterface; - -#[CoversClass(ResultConverter::class)] -#[Small] -#[UsesClass(Vector::class)] -#[UsesClass(VectorResult::class)] -class ResponseConverterTest extends TestCase -{ - #[Test] - public function itConvertsAResponseToAVectorResponse(): void - { - $result = $this->createStub(ResponseInterface::class); - $result - ->method('toArray') - ->willReturn(json_decode($this->getEmbeddingStub(), true)); - - $vectorResponse = (new ResultConverter())->convert(new RawHttpResult($result)); - $convertedContent = $vectorResponse->getContent(); - - self::assertCount(2, $convertedContent); - - self::assertSame([0.3, 0.4, 0.4], $convertedContent[0]->getData()); - self::assertSame([0.0, 0.0, 0.2], $convertedContent[1]->getData()); - } - - private function getEmbeddingStub(): string - { - return <<<'JSON' - { - "object": "list", - "data": [ - { - "object": "embedding", - "index": 0, - "embedding": [0.3, 0.4, 0.4] - }, - { - "object": "embedding", - "index": 1, - "embedding": [0.0, 0.0, 0.2] - } - ] - } - JSON; - } -} diff --git a/src/platform/tests/Contract/ResultConverter/VectorResultConverterTest.php b/src/platform/tests/Contract/ResultConverter/VectorResultConverterTest.php new file mode 100644 index 00000000..074708f0 --- /dev/null +++ b/src/platform/tests/Contract/ResultConverter/VectorResultConverterTest.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Contract\ResponseConverter; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Contract\ResultConverter\VectorResultConverter; +use Symfony\AI\Platform\Result\RawHttpResult; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\JsonMockResponse; + +#[CoversClass(VectorResultConverter::class)] +final class VectorResultConverterTest extends TestCase +{ + #[Test] + public function standardSuccess(): void + { + $httpClient = new MockHttpClient($this->jsonMockResponseFromFile(__DIR__.'/fixtures/standard-embeddings-success.json')); + $response = $httpClient->request('POST', 'https://api.example.com/v1/embeddings'); + + $converter = new VectorResultConverter(); + + $actual = $converter->convert(new RawHttpResult($response)); + self::assertCount(1, $actual->getContent()); + self::assertSame(5, $actual->getContent()[0]->getDimensions()); + } + + #[Test] + public function specificSuccess(): void + { + $httpClient = new MockHttpClient($this->jsonMockResponseFromFile(__DIR__.'/fixtures/specific-embeddings-success.json')); + $response = $httpClient->request('POST', 'https://api.example.com/v1/embeddings'); + + $converter = new VectorResultConverter('$.embeddings[*].values'); + + $actual = $converter->convert(new RawHttpResult($response)); + self::assertCount(1, $actual->getContent()); + self::assertSame(6, $actual->getContent()[0]->getDimensions()); + } + + /** + * This can be replaced by `JsonMockResponse::fromFile` when dropping Symfony 6.4. + */ + private function jsonMockResponseFromFile(string $file): JsonMockResponse + { + return new JsonMockResponse(json_decode(file_get_contents($file), true)); + } +} diff --git a/src/platform/tests/Contract/ResultConverter/fixtures/specific-embeddings-success.json b/src/platform/tests/Contract/ResultConverter/fixtures/specific-embeddings-success.json new file mode 100644 index 00000000..00f0bed2 --- /dev/null +++ b/src/platform/tests/Contract/ResultConverter/fixtures/specific-embeddings-success.json @@ -0,0 +1,14 @@ +{ + "embeddings": [ + { + "values": [ + 0.003826603, + -0.0008243268, + 0.016683463, + -0.08654552, + -0.0003032509, + 0.00097318884 + ] + } + ] +} diff --git a/src/platform/tests/Contract/ResultConverter/fixtures/standard-embeddings-success.json b/src/platform/tests/Contract/ResultConverter/fixtures/standard-embeddings-success.json new file mode 100644 index 00000000..d3374155 --- /dev/null +++ b/src/platform/tests/Contract/ResultConverter/fixtures/standard-embeddings-success.json @@ -0,0 +1,21 @@ +{ + "object": "list", + "data": [ + { + "object": "embedding", + "index": 0, + "embedding": [ + 0.013870293, + -0.024086641, + -0.017051745, + 0.059303116, + -0.038576424 + ] + } + ], + "model": "text-embedding-3-small", + "usage": { + "prompt_tokens": 64, + "total_tokens": 64 + } +}