Added AI embedding and phpstan analysis

This commit is contained in:
Tim Lappe 2025-05-30 22:19:43 +02:00
parent 65ef2ed89c
commit a3948fe32e
37 changed files with 1165 additions and 94 deletions

View File

@ -12,6 +12,8 @@
"ext-pdo": "*",
"ext-pgsql": "*",
"doctrine/doctrine-migrations-bundle": "^3.4",
"nyholm/psr7": "^1.8",
"openai-php/client": "^0.13.0",
"symfony/asset-mapper": "^7.3",
"symfony/console": "7.2.*",
"symfony/dotenv": "7.2.*",
@ -24,6 +26,8 @@
"symfony/yaml": "7.2.*"
},
"require-dev": {
"phpstan/phpstan": "^2.1",
"phpstan/phpstan-symfony": "^2.0",
"symfony/debug-bundle": "7.2.*",
"symfony/maker-bundle": "^1.0",
"symfony/var-dumper": "7.2.*"
@ -47,6 +51,7 @@
}
},
"scripts": {
"test": "phpstan analyse",
"auto-scripts": {
"cache:clear": "symfony-cmd",
"assets:install %PUBLIC_DIR%": "symfony-cmd",
@ -59,4 +64,4 @@
"@auto-scripts"
]
}
}
}

595
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "af2c42f4eb216435ec2aed6bb8b1dd11",
"content-hash": "aff00a8d8bd55186f046d02ac6fd7c4d",
"packages": [
{
"name": "composer/semver",
@ -792,6 +792,310 @@
},
"time": "2025-01-24T11:45:48+00:00"
},
{
"name": "nyholm/psr7",
"version": "1.8.2",
"source": {
"type": "git",
"url": "https://github.com/Nyholm/psr7.git",
"reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3",
"reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3",
"shasum": ""
},
"require": {
"php": ">=7.2",
"psr/http-factory": "^1.0",
"psr/http-message": "^1.1 || ^2.0"
},
"provide": {
"php-http/message-factory-implementation": "1.0",
"psr/http-factory-implementation": "1.0",
"psr/http-message-implementation": "1.0"
},
"require-dev": {
"http-interop/http-factory-tests": "^0.9",
"php-http/message-factory": "^1.0",
"php-http/psr7-integration-tests": "^1.0",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.4",
"symfony/error-handler": "^4.4"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.8-dev"
}
},
"autoload": {
"psr-4": {
"Nyholm\\Psr7\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Tobias Nyholm",
"email": "tobias.nyholm@gmail.com"
},
{
"name": "Martijn van der Ven",
"email": "martijn@vanderven.se"
}
],
"description": "A fast PHP7 implementation of PSR-7",
"homepage": "https://tnyholm.se",
"keywords": [
"psr-17",
"psr-7"
],
"support": {
"issues": "https://github.com/Nyholm/psr7/issues",
"source": "https://github.com/Nyholm/psr7/tree/1.8.2"
},
"funding": [
{
"url": "https://github.com/Zegnat",
"type": "github"
},
{
"url": "https://github.com/nyholm",
"type": "github"
}
],
"time": "2024-09-09T07:06:30+00:00"
},
{
"name": "openai-php/client",
"version": "v0.13.0",
"source": {
"type": "git",
"url": "https://github.com/openai-php/client.git",
"reference": "399229860cea244843753bf1d9c28aee0e74c3a6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/openai-php/client/zipball/399229860cea244843753bf1d9c28aee0e74c3a6",
"reference": "399229860cea244843753bf1d9c28aee0e74c3a6",
"shasum": ""
},
"require": {
"php": "^8.2.0",
"php-http/discovery": "^1.20.0",
"php-http/multipart-stream-builder": "^1.4.2",
"psr/http-client": "^1.0.3",
"psr/http-client-implementation": "^1.0.1",
"psr/http-factory-implementation": "*",
"psr/http-message": "^1.1.0|^2.0.0"
},
"require-dev": {
"guzzlehttp/guzzle": "^7.9.3",
"guzzlehttp/psr7": "^2.7.1",
"laravel/pint": "^1.22.0",
"mockery/mockery": "^1.6.12",
"nunomaduro/collision": "^8.8.0",
"pestphp/pest": "^3.8.2|^4.0.0",
"pestphp/pest-plugin-arch": "^3.1.1|^4.0.0",
"pestphp/pest-plugin-type-coverage": "^3.5.1|^4.0.0",
"phpstan/phpstan": "^1.12.25",
"symfony/var-dumper": "^7.2.6"
},
"type": "library",
"autoload": {
"files": [
"src/OpenAI.php"
],
"psr-4": {
"OpenAI\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nuno Maduro",
"email": "enunomaduro@gmail.com"
},
{
"name": "Sandro Gehri"
}
],
"description": "OpenAI PHP is a supercharged PHP API client that allows you to interact with the Open AI API",
"keywords": [
"GPT-3",
"api",
"client",
"codex",
"dall-e",
"language",
"natural",
"openai",
"php",
"processing",
"sdk"
],
"support": {
"issues": "https://github.com/openai-php/client/issues",
"source": "https://github.com/openai-php/client/tree/v0.13.0"
},
"funding": [
{
"url": "https://www.paypal.com/paypalme/enunomaduro",
"type": "custom"
},
{
"url": "https://github.com/gehrisandro",
"type": "github"
},
{
"url": "https://github.com/nunomaduro",
"type": "github"
}
],
"time": "2025-05-14T21:43:59+00:00"
},
{
"name": "php-http/discovery",
"version": "1.20.0",
"source": {
"type": "git",
"url": "https://github.com/php-http/discovery.git",
"reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d",
"reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d",
"shasum": ""
},
"require": {
"composer-plugin-api": "^1.0|^2.0",
"php": "^7.1 || ^8.0"
},
"conflict": {
"nyholm/psr7": "<1.0",
"zendframework/zend-diactoros": "*"
},
"provide": {
"php-http/async-client-implementation": "*",
"php-http/client-implementation": "*",
"psr/http-client-implementation": "*",
"psr/http-factory-implementation": "*",
"psr/http-message-implementation": "*"
},
"require-dev": {
"composer/composer": "^1.0.2|^2.0",
"graham-campbell/phpspec-skip-example-extension": "^5.0",
"php-http/httplug": "^1.0 || ^2.0",
"php-http/message-factory": "^1.0",
"phpspec/phpspec": "^5.1 || ^6.1 || ^7.3",
"sebastian/comparator": "^3.0.5 || ^4.0.8",
"symfony/phpunit-bridge": "^6.4.4 || ^7.0.1"
},
"type": "composer-plugin",
"extra": {
"class": "Http\\Discovery\\Composer\\Plugin",
"plugin-optional": true
},
"autoload": {
"psr-4": {
"Http\\Discovery\\": "src/"
},
"exclude-from-classmap": [
"src/Composer/Plugin.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com"
}
],
"description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations",
"homepage": "http://php-http.org",
"keywords": [
"adapter",
"client",
"discovery",
"factory",
"http",
"message",
"psr17",
"psr7"
],
"support": {
"issues": "https://github.com/php-http/discovery/issues",
"source": "https://github.com/php-http/discovery/tree/1.20.0"
},
"time": "2024-10-02T11:20:13+00:00"
},
{
"name": "php-http/multipart-stream-builder",
"version": "1.4.2",
"source": {
"type": "git",
"url": "https://github.com/php-http/multipart-stream-builder.git",
"reference": "10086e6de6f53489cca5ecc45b6f468604d3460e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-http/multipart-stream-builder/zipball/10086e6de6f53489cca5ecc45b6f468604d3460e",
"reference": "10086e6de6f53489cca5ecc45b6f468604d3460e",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0",
"php-http/discovery": "^1.15",
"psr/http-factory-implementation": "^1.0"
},
"require-dev": {
"nyholm/psr7": "^1.0",
"php-http/message": "^1.5",
"php-http/message-factory": "^1.0.2",
"phpunit/phpunit": "^7.5.15 || ^8.5 || ^9.3"
},
"type": "library",
"autoload": {
"psr-4": {
"Http\\Message\\MultipartStream\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Tobias Nyholm",
"email": "tobias.nyholm@gmail.com"
}
],
"description": "A builder class that help you create a multipart stream",
"homepage": "http://php-http.org",
"keywords": [
"factory",
"http",
"message",
"multipart stream",
"stream"
],
"support": {
"issues": "https://github.com/php-http/multipart-stream-builder/issues",
"source": "https://github.com/php-http/multipart-stream-builder/tree/1.4.2"
},
"time": "2024-09-04T13:22:54+00:00"
},
{
"name": "psr/cache",
"version": "3.0.0",
@ -944,6 +1248,166 @@
},
"time": "2019-01-08T18:20:26+00:00"
},
{
"name": "psr/http-client",
"version": "1.0.3",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-client.git",
"reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90",
"reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90",
"shasum": ""
},
"require": {
"php": "^7.0 || ^8.0",
"psr/http-message": "^1.0 || ^2.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Client\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for HTTP clients",
"homepage": "https://github.com/php-fig/http-client",
"keywords": [
"http",
"http-client",
"psr",
"psr-18"
],
"support": {
"source": "https://github.com/php-fig/http-client"
},
"time": "2023-09-23T14:17:50+00:00"
},
{
"name": "psr/http-factory",
"version": "1.1.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-factory.git",
"reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
"reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
"shasum": ""
},
"require": {
"php": ">=7.1",
"psr/http-message": "^1.0 || ^2.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Message\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "PSR-17: Common interfaces for PSR-7 HTTP message factories",
"keywords": [
"factory",
"http",
"message",
"psr",
"psr-17",
"psr-7",
"request",
"response"
],
"support": {
"source": "https://github.com/php-fig/http-factory"
},
"time": "2024-04-15T12:06:14+00:00"
},
{
"name": "psr/http-message",
"version": "2.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-message.git",
"reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
"reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Message\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for HTTP messages",
"homepage": "https://github.com/php-fig/http-message",
"keywords": [
"http",
"http-message",
"psr",
"psr-7",
"request",
"response"
],
"support": {
"source": "https://github.com/php-fig/http-message/tree/2.0"
},
"time": "2023-04-04T09:54:51+00:00"
},
{
"name": "psr/log",
"version": "3.0.2",
@ -4410,6 +4874,135 @@
},
"time": "2024-12-30T11:07:19+00:00"
},
{
"name": "phpstan/phpstan",
"version": "2.1.17",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan.git",
"reference": "89b5ef665716fa2a52ecd2633f21007a6a349053"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/89b5ef665716fa2a52ecd2633f21007a6a349053",
"reference": "89b5ef665716fa2a52ecd2633f21007a6a349053",
"shasum": ""
},
"require": {
"php": "^7.4|^8.0"
},
"conflict": {
"phpstan/phpstan-shim": "*"
},
"bin": [
"phpstan",
"phpstan.phar"
],
"type": "library",
"autoload": {
"files": [
"bootstrap.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHPStan - PHP Static Analysis Tool",
"keywords": [
"dev",
"static analysis"
],
"support": {
"docs": "https://phpstan.org/user-guide/getting-started",
"forum": "https://github.com/phpstan/phpstan/discussions",
"issues": "https://github.com/phpstan/phpstan/issues",
"security": "https://github.com/phpstan/phpstan/security/policy",
"source": "https://github.com/phpstan/phpstan-src"
},
"funding": [
{
"url": "https://github.com/ondrejmirtes",
"type": "github"
},
{
"url": "https://github.com/phpstan",
"type": "github"
}
],
"time": "2025-05-21T20:55:28+00:00"
},
{
"name": "phpstan/phpstan-symfony",
"version": "2.0.6",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan-symfony.git",
"reference": "5005288e07583546ea00b52de4a9ac412eb869d7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/5005288e07583546ea00b52de4a9ac412eb869d7",
"reference": "5005288e07583546ea00b52de4a9ac412eb869d7",
"shasum": ""
},
"require": {
"ext-simplexml": "*",
"php": "^7.4 || ^8.0",
"phpstan/phpstan": "^2.1.13"
},
"conflict": {
"symfony/framework-bundle": "<3.0"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "^1.2",
"phpstan/phpstan-phpunit": "^2.0",
"phpstan/phpstan-strict-rules": "^2.0",
"phpunit/phpunit": "^9.6",
"psr/container": "1.1.2",
"symfony/config": "^5.4 || ^6.1",
"symfony/console": "^5.4 || ^6.1",
"symfony/dependency-injection": "^5.4 || ^6.1",
"symfony/form": "^5.4 || ^6.1",
"symfony/framework-bundle": "^5.4 || ^6.1",
"symfony/http-foundation": "^5.4 || ^6.1",
"symfony/messenger": "^5.4",
"symfony/polyfill-php80": "^1.24",
"symfony/serializer": "^5.4",
"symfony/service-contracts": "^2.2.0"
},
"type": "phpstan-extension",
"extra": {
"phpstan": {
"includes": [
"extension.neon",
"rules.neon"
]
}
},
"autoload": {
"psr-4": {
"PHPStan\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Lukáš Unger",
"email": "looky.msc@gmail.com",
"homepage": "https://lookyman.net"
}
],
"description": "Symfony Framework extensions and rules for PHPStan",
"support": {
"issues": "https://github.com/phpstan/phpstan-symfony/issues",
"source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.6"
},
"time": "2025-05-14T07:00:05+00:00"
},
{
"name": "symfony/debug-bundle",
"version": "v7.2.0",

View File

@ -0,0 +1,10 @@
services:
Psr\Http\Message\RequestFactoryInterface: '@http_discovery.psr17_factory'
Psr\Http\Message\ResponseFactoryInterface: '@http_discovery.psr17_factory'
Psr\Http\Message\ServerRequestFactoryInterface: '@http_discovery.psr17_factory'
Psr\Http\Message\StreamFactoryInterface: '@http_discovery.psr17_factory'
Psr\Http\Message\UploadedFileFactoryInterface: '@http_discovery.psr17_factory'
Psr\Http\Message\UriFactoryInterface: '@http_discovery.psr17_factory'
http_discovery.psr17_factory:
class: Http\Discovery\Psr17Factory

View File

@ -0,0 +1,3 @@
<?php // dev.OPENAI_API_KEY.66949e on Fri, 30 May 2025 19:25:09 +0000
return "\x13\xABF\xCB\x3C\xE8\x5B\xBB\xD1\x5D\x09\xAEe\x26\x04o\x7B.\xEF\xCB\xCF3\x01N\xFEh\x93\x2C\xD2\xD4\x3B\x1E\xD4\xF0\xBD\x3F\x17jO5\xD9\x02F\x93Z\xB6\xDEN\x18\xECI\xDC6vh\x2C\x11\xBFS\xB1\x04\xE0\xB8q\xF1m\xD48\x1D\x7B0\x2B\xB9\x1C\xB1\x8A\x3D\xFE\x8F\xC6ux\x8D\x07\xD1\xB0\x7C\x8C\x80\xB6\xB0o\xDB\x7B\xF1O\x1C\x26\xA6\x22\xBABu\xD9\x2B\x2A\x5Dy\xFC\xB5l\xE7\x0F\x1D\xFD\xDB\x16\xFE\xED\x3D\x5D\x04\x5B\x1C\xCC\x8B\x0D\xE9OC\xE9\xE524\xD52\x28\x13\x3E\xD1b\xA8-\x97\xDFm\xAA\x92D\xE9\xFA\x3B\xD3b\xF7\xA0\xEB\x8D\xE2o\xD0\xC8\x0Cn\x8AP\x87\xF3\x93\x23\xB1\xFC\x85\xD3\xCA\xA7\xAE\x15P\xA8S\xB6\x10\xD8\xAA\x91\x2C\x94\xB6vG\x12\xDE\xE9\x2F\xFA\xAE\xB5\xD0X4\x06rq\xE3\xC4Ad\x99\xA0\xF0\x01";

View File

@ -0,0 +1,4 @@
<?php // dev.decrypt.private on Fri, 30 May 2025 19:25:09 +0000
// SYMFONY_DECRYPTION_SECRET=MEmDRd3Ow/LTcmFwCZzQpyTZ8ZYh5zAFgpxkvzsHCzslofchkhzLbDqY85F5cAFxIokpFudvGr99tO3hHdnPDg==
return "0I\x83E\xDD\xCE\xC3\xF2\xD3rap\x09\x9C\xD0\xA7\x24\xD9\xF1\x96\x21\xE70\x05\x82\x9Cd\xBF\x3B\x07\x0B\x3B\x25\xA1\xF7\x21\x92\x1C\xCBl\x3A\x98\xF3\x91yp\x01q\x22\x89\x29\x16\xE7o\x1A\xBF\x7D\xB4\xED\xE1\x1D\xD9\xCF\x0E";

View File

@ -0,0 +1,3 @@
<?php // dev.encrypt.public on Fri, 30 May 2025 19:25:09 +0000
return "\x25\xA1\xF7\x21\x92\x1C\xCBl\x3A\x98\xF3\x91yp\x01q\x22\x89\x29\x16\xE7o\x1A\xBF\x7D\xB4\xED\xE1\x1D\xD9\xCF\x0E";

View File

@ -0,0 +1,5 @@
<?php
return [
'OPENAI_API_KEY' => null,
];

View File

@ -10,4 +10,8 @@ services:
exclude:
- '../src/DependencyInjection/'
- '../src/Entity/'
- '../src/Kernel.php'
- '../src/Kernel.php'
App\Domain\AI\AIClient:
arguments:
$apiKey: '%env(OPENAI_API_KEY)%'

View File

@ -17,7 +17,7 @@ services:
- proxy
database:
image: postgres:17-alpine
image: pgvector/pgvector:pg17
hostname: database.evwiki.test
environment:
- POSTGRES_USER=postgres

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250530193246 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create embeddings table';
}
public function up(Schema $schema): void
{
$this->addSql(<<<SQL
CREATE TABLE embeddings (
phrase_hash VARCHAR(255) NOT NULL PRIMARY KEY,
phrase VARCHAR(255) NOT NULL,
large_embedding_vector VECTOR(3072),
small_embedding_vector VECTOR(1536),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)
SQL);
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE embeddings');
}
}

16
phpstan.neon Normal file
View File

@ -0,0 +1,16 @@
includes:
- vendor/phpstan/phpstan-symfony/extension.neon
parameters:
level: 10
paths:
- src
- bin
excludePaths:
- src/Kernel.php (?)
- var/*
- vendor/*
# Bootstrap file for better analysis
bootstrapFiles:
- vendor/autoload.php

View File

@ -0,0 +1,65 @@
<?php
namespace App\Application\Commands;
use App\Domain\AI\AIClient;
use App\Domain\Model\Persistence\PersistedEmbedding;
use App\Domain\Repository\EmbeddingRepository;
use App\Domain\Model\AI\Embedding;
use App\Domain\Model\AI\LargeEmbeddingVector;
use App\Domain\Model\Value\Vector;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
#[AsCommand(
name: 'app:ai-client',
description: 'AIClient command'
)]
class AIClientCommand extends Command
{
public function __construct(
private readonly AIClient $aiClient,
private readonly EmbeddingRepository $embeddingRepository,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('text', InputArgument::REQUIRED, 'The text to embed');
$this->addOption('embed', null, InputOption::VALUE_NONE, 'Embed the text');
$this->addOption('search', null, InputOption::VALUE_NONE, 'Search for similar texts');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$textArg = $input->getArgument('text');
$text = is_string($textArg) ? $textArg : '';
if ($input->getOption('embed')) {
$persistedEmbedding = $this->embedText($text);
$output->writeln($persistedEmbedding->phraseHash);
} else if ($input->getOption('search')) {
$results = $this->embeddingRepository->searchByLargeEmbeddingVector(new LargeEmbeddingVector(new Vector($this->aiClient->embedText($text))));
foreach ($results as $result) {
$output->writeln($result->embedding->phrase);
}
} else {
$output->writeln($this->aiClient->generateText($text));
}
return Command::SUCCESS;
}
private function embedText(string $text): PersistedEmbedding
{
$embedding = $this->aiClient->embedText($text);
$vector = new Vector($embedding);
return $this->embeddingRepository->create(new Embedding($text, new LargeEmbeddingVector($vector)));
}
}

View File

@ -37,7 +37,9 @@ use Symfony\Component\Console\Style\SymfonyStyle;
)]
class LoadFixtures extends Command
{
/** @var array<string, string> */
private array $brandIds = [];
/** @var array<string, string> */
private array $carModelIds = [];
public function __construct(
@ -73,11 +75,14 @@ class LoadFixtures extends Command
$io->progressStart(count($carModels));
foreach ($carModels as $carModelData) {
$model = $carModelData['model'];
$brandName = $carModelData['brand'];
$persistedCarModel = $this->carModelRepository->create(
$carModelData['model'],
$this->brandIds[$carModelData['brand']]
$model,
$this->brandIds[$brandName]
);
$this->carModelIds[$carModelData['model']->name] = $persistedCarModel->id;
$this->carModelIds[$model->name] = $persistedCarModel->id;
$io->progressAdvance();
}
@ -90,9 +95,12 @@ class LoadFixtures extends Command
$io->progressStart(count($carRevisions));
foreach ($carRevisions as $carRevisionData) {
$revision = $carRevisionData['revision'];
$modelName = $carRevisionData['model'];
$this->carRevisionRepository->create(
$carRevisionData['revision'],
$this->carModelIds[$carRevisionData['model']]
$revision,
$this->carModelIds[$modelName]
);
$io->progressAdvance();
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Domain\AI;
use OpenAI;
class AIClient
{
private readonly OpenAI\Client $client;
public function __construct(
private readonly string $apiKey,
) {
$this->client = OpenAI::client($this->apiKey);
}
public function generateText(string $prompt): string
{
$response = $this->client->chat()->create([
'model' => 'gpt-4o-mini',
'messages' => [
['role' => 'user', 'content' => $prompt],
],
]);
return $response->choices[0]->message->content ?? '';
}
/**
* @return float[]
*/
public function embedText(string $text): array
{
$response = $this->client->embeddings()->create([
'model' => 'text-embedding-3-large',
'input' => $text,
]);
return $response->embeddings[0]->embedding;
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Domain\Model\AI;
class Embedding
{
public function __construct(
public readonly string $phrase,
public readonly ?LargeEmbeddingVector $largeEmbeddingVector = null,
public readonly ?SmallEmbeddingVector $smallEmbeddingVector = null,
) {
if ($largeEmbeddingVector === null && $smallEmbeddingVector === null) {
throw new \InvalidArgumentException('At least one embedding vector must be provided');
}
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Domain\Model\AI;
use App\Domain\Model\Value\Vector;
class LargeEmbeddingVector
{
public const DIMENSION = 3072;
public function __construct(
public readonly Vector $vector,
) {
if ($vector->dimension() !== self::DIMENSION) {
throw new \InvalidArgumentException('Vector must be ' . self::DIMENSION . ' dimensions');
}
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Domain\Model\AI;
use App\Domain\Model\Value\Vector;
class SmallEmbeddingVector
{
public const DIMENSION = 1536;
public function __construct(
public readonly Vector $vector,
) {
if ($vector->dimension() !== self::DIMENSION) {
throw new \InvalidArgumentException('Vector must be ' . self::DIMENSION . ' dimensions');
}
}
}

View File

@ -4,11 +4,17 @@ namespace App\Domain\Model;
class BrandCollection
{
/**
* @param Brand[] $brands
*/
public function __construct(
private readonly array $brands,
) {
}
/**
* @return Brand[]
*/
public function array(): array
{
return $this->brands;

View File

@ -4,11 +4,17 @@ namespace App\Domain\Model;
class CarModelCollection
{
/**
* @param CarModel[] $carModels
*/
public function __construct(
private readonly array $carModels,
) {
}
/**
* @return CarModel[]
*/
public function array(): array
{
return $this->carModels;

View File

@ -4,11 +4,17 @@ namespace App\Domain\Model;
class CarRevisionCollection
{
/**
* @param CarRevision[] $carRevisions
*/
public function __construct(
private readonly array $carRevisions,
) {
}
/**
* @return CarRevision[]
*/
public function array(): array
{
return $this->carRevisions;

View File

@ -0,0 +1,14 @@
<?php
namespace App\Domain\Model\Persistence;
use App\Domain\Model\AI\Embedding;
class PersistedEmbedding
{
public function __construct(
public readonly string $phraseHash,
public readonly Embedding $embedding,
) {
}
}

View File

@ -4,6 +4,7 @@ namespace App\Domain\Model;
use App\Domain\Model\Range\NefzRange;
use App\Domain\Model\Range\WltpRange;
use App\Domain\Model\Range\RealRange;
final readonly class RangeProperties
{

View File

@ -0,0 +1,19 @@
<?php
namespace App\Domain\Model\Value;
class Vector
{
/**
* @param float[] $values
*/
public function __construct(
public readonly array $values,
) {
}
public function dimension(): int
{
return count($this->values);
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Domain\Repository;
use App\Domain\Model\AI\Embedding;
use App\Domain\Model\AI\LargeEmbeddingVector;
use App\Domain\Model\AI\SmallEmbeddingVector;
use App\Domain\Model\Persistence\PersistedEmbedding;
use App\Domain\Model\Value\Vector;
interface EmbeddingRepository
{
public function create(Embedding $embedding): PersistedEmbedding;
public function delete(PersistedEmbedding $persistedEmbedding): void;
/**
* @param LargeEmbeddingVector $embeddingVector
* @param int $limit
* @return PersistedEmbedding[]
*/
public function searchByLargeEmbeddingVector(LargeEmbeddingVector $embeddingVector, int $limit = 10): array;
/**
* @param SmallEmbeddingVector $vector
* @param int $limit
* @return PersistedEmbedding[]
*/
public function searchBySmallEmbeddingVector(SmallEmbeddingVector $vector, int $limit = 10): array;
}

View File

@ -134,36 +134,36 @@ class Engine
return new TileCollection([
new SectionTile('Skoda Enyaq iV 85', [
new CarTile($skodaElroq85->image, [
new CarTile($skodaElroq85->image ?? new Image(), array_filter([
new BrandTile('Skoda'),
new PriceTile($skodaElroq85->catalogPrice),
$skodaElroq85->catalogPrice ? new PriceTile($skodaElroq85->catalogPrice) : null,
new AvailabilityTile('Verfügbar', new Date(1, 1, 2020)),
new RangeTile($wltpRange->range),
new ConsumptionTile($drivingCharacteristics->consumption),
new AccelerationTile($drivingCharacteristics->acceleration),
]),
$drivingCharacteristics->consumption ? new ConsumptionTile($drivingCharacteristics->consumption) : null,
$drivingCharacteristics->acceleration ? new AccelerationTile($drivingCharacteristics->acceleration) : null,
])),
new SubSectionTile('Performance', [
new PowerTile($drivingCharacteristics->power),
new TopSpeedTile($drivingCharacteristics->topSpeed),
new SubSectionTile('Performance', array_filter([
$drivingCharacteristics->power ? new PowerTile($drivingCharacteristics->power) : null,
$drivingCharacteristics->topSpeed ? new TopSpeedTile($drivingCharacteristics->topSpeed) : null,
new DrivetrainTile(new Drivetrain('rear')),
], 'Individual performance metrics'),
])),
new SubSectionTile('Reichweite', [
new RangeTile($wltpRange->range),
new RealRangeTile($realRangeTests),
], 'Range data from different sources'),
]),
new SubSectionTile('Batterie', [
new BatteryTile($skodaElroq85->battery),
new BatteryDetailsTile($skodaElroq85->battery),
], 'Battery capacity and technology'),
new SubSectionTile('Batterie', array_filter([
$skodaElroq85->battery ? new BatteryTile($skodaElroq85->battery) : null,
$skodaElroq85->battery ? new BatteryDetailsTile($skodaElroq85->battery) : null,
])),
new SubSectionTile('Laden', [
new SubSectionTile('Laden', array_filter([
new ChargingTile($chargingSpeed),
new ChargeTimeTile($chargingProperties->chargeTimeProperties),
new ChargingConnectivityTile($chargingProperties->chargingConnectivity),
], 'Charging capabilities and compatibility'),
$chargingProperties->chargeTimeProperties ? new ChargeTimeTile($chargingProperties->chargeTimeProperties) : null,
$chargingProperties->chargingConnectivity ? new ChargingConnectivityTile($chargingProperties->chargingConnectivity) : null,
])),
]),
]);
}

View File

@ -6,10 +6,16 @@ use App\Domain\Search\Tiles\SectionTile;
class TileCollection
{
/**
* @param SectionTile[] $tiles
*/
public function __construct(
private readonly array $tiles,
) {}
/**
* @return SectionTile[]
*/
public function array(): array
{
return $this->tiles;

View File

@ -6,6 +6,9 @@ use App\Domain\Model\Image;
final readonly class CarTile
{
/**
* @param object[] $tiles
*/
public function __construct(
public Image $image,
public array $tiles

View File

@ -4,6 +4,9 @@ namespace App\Domain\Search\Tiles;
class SectionTile
{
/**
* @param object[] $tiles
*/
public function __construct(
public readonly string $title,
public readonly array $tiles,

View File

@ -4,6 +4,9 @@ namespace App\Domain\Search\Tiles;
class SubSectionTile
{
/**
* @param object[] $tiles
*/
public function __construct(
public readonly string $title,
public readonly array $tiles,

View File

@ -6,17 +6,18 @@ use App\Domain\Model\Brand;
class ModelMapper
{
/**
* @param array<string, mixed> $data
*/
public function map(array $data): Brand
{
return new Brand(
id: (string) ($data['id'] ?? null),
name: $data['name'] ?? '',
logo: $data['logo'] ?? '',
description: $data['description'] ?? '',
foundedYear: (int) ($data['founded_year'] ?? 0),
headquarters: $data['headquarters'] ?? '',
website: $data['website'] ?? '',
carModels: json_decode($data['car_models'] ?? '[]', true),
name: is_string($data['name'] ?? null) ? $data['name'] : '',
logo: isset($data['logo']) && is_string($data['logo']) ? $data['logo'] : null,
description: isset($data['description']) && is_string($data['description']) ? $data['description'] : null,
foundedYear: isset($data['founded_year']) && is_numeric($data['founded_year']) ? (int) $data['founded_year'] : null,
headquarters: isset($data['headquarters']) && is_string($data['headquarters']) ? $data['headquarters'] : null,
website: isset($data['website']) && is_string($data['website']) ? $data['website'] : null,
);
}
}

View File

@ -23,7 +23,7 @@ final class PostgreSQLBrandRepository implements BrandRepository
$brands = [];
$mapper = new ModelMapper();
foreach ($result as $brand) {
foreach ($result->fetchAllAssociative() as $brand) {
$brands[] = $mapper->map($brand);
}

View File

@ -7,17 +7,21 @@ use App\Domain\Model\Brand;
class ModelMapper
{
/**
* @param array<string, mixed> $data
*/
public function map(array $data): CarModel
{
$content = json_decode($data['content'] ?? '{}', true);
$contentString = is_string($data['content'] ?? null) ? $data['content'] : '{}';
$content = json_decode($contentString, true);
$brand = null;
if (!empty($content['brand'])) {
$brand = new Brand($content['brand']);
if (is_array($content) && !empty($content['brand']) && is_string($content['brand'])) {
$brand = new Brand(name: $content['brand']);
}
return new CarModel(
name: $data['name'] ?? '',
name: is_string($data['name'] ?? null) ? $data['name'] : '',
brand: $brand,
);
}

View File

@ -23,7 +23,7 @@ final class PostgreSQLCarModelRepository implements CarModelRepository
$carModels = [];
$mapper = new ModelMapper();
foreach ($result as $carModel) {
foreach ($result->fetchAllAssociative() as $carModel) {
$carModels[] = $mapper->map($carModel);
}
@ -43,7 +43,7 @@ final class PostgreSQLCarModelRepository implements CarModelRepository
$mapper = new ModelMapper();
$carModel = $mapper->map($data);
return new PersistedCarModel($data['id'], $carModel);
return new PersistedCarModel(is_string($data['id'] ?? null) ? $data['id'] : '', $carModel);
}
public function findByBrandId(string $brandId): CarModelCollection
@ -54,7 +54,7 @@ final class PostgreSQLCarModelRepository implements CarModelRepository
$carModels = [];
$mapper = new ModelMapper();
foreach ($result as $carModel) {
foreach ($result->fetchAllAssociative() as $carModel) {
$carModels[] = $mapper->map($carModel);
}
@ -76,7 +76,7 @@ final class PostgreSQLCarModelRepository implements CarModelRepository
SQL;
$content = json_encode([
'brand' => $carModel->brand?->name ?? null,
'brand' => $carModel->brand->name ?? null,
]);
$this->connection->executeStatement($sql, [
@ -101,7 +101,7 @@ final class PostgreSQLCarModelRepository implements CarModelRepository
SQL;
$content = json_encode([
'brand' => $carModel->brand?->name ?? null,
'brand' => $carModel->brand->name ?? null,
]);
$this->connection->executeStatement($sql, [

View File

@ -23,22 +23,31 @@ use App\Domain\Model\Value\Range;
class ModelMapper
{
/**
* @param array<string, mixed> $data
*/
public function map(array $data): CarRevision
{
$content = json_decode($data['content'] ?? '{}', true);
$contentString = is_string($data['content'] ?? null) ? $data['content'] : '{}';
$content = json_decode($contentString, true);
if (!is_array($content)) {
$content = [];
}
$productionBegin = null;
if (!empty($content['production_begin'])) {
$productionBegin = new Date(1, 1, $content['production_begin']);
if (isset($content['production_begin']) && is_numeric($content['production_begin'])) {
$productionBegin = new Date(1, 1, (int) $content['production_begin']);
}
$productionEnd = null;
if (!empty($content['production_end'])) {
$productionEnd = new Date(1, 1, $content['production_end']);
if (isset($content['production_end']) && is_numeric($content['production_end'])) {
$productionEnd = new Date(1, 1, (int) $content['production_end']);
}
$catalogPrice = null;
if (!empty($content['catalog_price']) && !empty($content['catalog_price_currency'])) {
if (isset($content['catalog_price']) && isset($content['catalog_price_currency']) &&
is_numeric($content['catalog_price']) && is_string($content['catalog_price_currency'])) {
$currency = match($content['catalog_price_currency']) {
'EUR' => Currency::euro(),
'USD' => Currency::usd(),
@ -46,53 +55,56 @@ class ModelMapper
};
$catalogPrice = new Price(
$content['catalog_price'],
(int) $content['catalog_price'],
$currency
);
}
$image = null;
if (!empty($content['image_url'])) {
if (isset($content['image_url']) && is_string($content['image_url'])) {
$image = new Image($content['image_url']);
}
$drivingCharacteristics = null;
if (!empty($content['driving_characteristics'])) {
if (isset($content['driving_characteristics']) && is_array($content['driving_characteristics'])) {
$dc = $content['driving_characteristics'];
$drivingCharacteristics = new DrivingCharacteristics(
power: !empty($dc['power_kw']) ? new Power($dc['power_kw']) : null,
acceleration: !empty($dc['acceleration_0_100']) ? new Acceleration($dc['acceleration_0_100']) : null,
topSpeed: !empty($dc['top_speed_kmh']) ? new Speed($dc['top_speed_kmh']) : null,
consumption: !empty($dc['consumption_kwh_100km']) ? new Consumption(new Energy($dc['consumption_kwh_100km'])) : null,
power: (isset($dc['power_kw']) && is_numeric($dc['power_kw'])) ? new Power((float) $dc['power_kw']) : null,
acceleration: (isset($dc['acceleration_0_100']) && is_numeric($dc['acceleration_0_100'])) ? new Acceleration((float) $dc['acceleration_0_100']) : null,
topSpeed: (isset($dc['top_speed_kmh']) && is_numeric($dc['top_speed_kmh'])) ? new Speed((int) $dc['top_speed_kmh']) : null,
consumption: (isset($dc['consumption_kwh_100km']) && is_numeric($dc['consumption_kwh_100km'])) ? new Consumption(new Energy((float) $dc['consumption_kwh_100km'])) : null,
);
}
$battery = null;
if (!empty($content['battery'])) {
if (isset($content['battery']) && is_array($content['battery'])) {
$b = $content['battery'];
if (!empty($b['usable_capacity_kwh']) && !empty($b['total_capacity_kwh'])) {
if (isset($b['usable_capacity_kwh']) && isset($b['total_capacity_kwh']) &&
is_numeric($b['usable_capacity_kwh']) && is_numeric($b['total_capacity_kwh'])) {
$battery = new BatteryProperties(
usableCapacity: new Energy($b['usable_capacity_kwh']),
totalCapacity: new Energy($b['total_capacity_kwh']),
cellChemistry: !empty($b['cell_chemistry']) ? CellChemistry::from($b['cell_chemistry']) : CellChemistry::LithiumIronPhosphate,
model: $b['model'] ?? '',
manufacturer: $b['manufacturer'] ?? '',
usableCapacity: new Energy((float) $b['usable_capacity_kwh']),
totalCapacity: new Energy((float) $b['total_capacity_kwh']),
cellChemistry: (isset($b['cell_chemistry']) && (is_string($b['cell_chemistry']) || is_int($b['cell_chemistry']))) ? CellChemistry::from($b['cell_chemistry']) : CellChemistry::LithiumIronPhosphate,
model: is_string($b['model'] ?? null) ? $b['model'] : '',
manufacturer: is_string($b['manufacturer'] ?? null) ? $b['manufacturer'] : '',
);
}
}
$chargingProperties = null;
if (!empty($content['charging']['top_charging_speed_kw'])) {
if (isset($content['charging']) && is_array($content['charging']) &&
isset($content['charging']['top_charging_speed_kw']) &&
is_numeric($content['charging']['top_charging_speed_kw'])) {
$chargingProperties = new ChargingProperties(
topChargingSpeed: new Power($content['charging']['top_charging_speed_kw'])
topChargingSpeed: new Power((float) $content['charging']['top_charging_speed_kw'])
);
}
$rangeProperties = null;
if (!empty($content['range'])) {
if (isset($content['range']) && is_array($content['range'])) {
$r = $content['range'];
$wltp = !empty($r['wltp_km']) ? new WltpRange(new Range($r['wltp_km'])) : null;
$nefz = !empty($r['nefz_km']) ? new NefzRange(new Range($r['nefz_km'])) : null;
$wltp = (isset($r['wltp_km']) && is_numeric($r['wltp_km'])) ? new WltpRange(new Range((int) $r['wltp_km'])) : null;
$nefz = (isset($r['nefz_km']) && is_numeric($r['nefz_km'])) ? new NefzRange(new Range((int) $r['nefz_km'])) : null;
if ($wltp || $nefz) {
$rangeProperties = new RangeProperties(
@ -103,7 +115,7 @@ class ModelMapper
}
return new CarRevision(
name: $data['name'] ?? '',
name: is_string($data['name'] ?? null) ? $data['name'] : '',
productionBegin: $productionBegin,
productionEnd: $productionEnd,
drivingCharacteristics: $drivingCharacteristics,

View File

@ -23,7 +23,7 @@ final class PostgreSQLCarRevisionRepository implements CarRevisionRepository
$carRevisions = [];
$mapper = new ModelMapper();
foreach ($result as $carRevision) {
foreach ($result->fetchAllAssociative() as $carRevision) {
$carRevisions[] = $mapper->map($carRevision);
}
@ -43,7 +43,7 @@ final class PostgreSQLCarRevisionRepository implements CarRevisionRepository
$mapper = new ModelMapper();
$carRevision = $mapper->map($data);
return new PersistedCarRevision($data['id'], $carRevision);
return new PersistedCarRevision(is_string($data['id'] ?? null) ? $data['id'] : '', $carRevision);
}
public function findByCarModelId(string $carModelId): CarRevisionCollection
@ -54,7 +54,7 @@ final class PostgreSQLCarRevisionRepository implements CarRevisionRepository
$carRevisions = [];
$mapper = new ModelMapper();
foreach ($result as $carRevision) {
foreach ($result->fetchAllAssociative() as $carRevision) {
$carRevisions[] = $mapper->map($carRevision);
}
@ -76,26 +76,26 @@ final class PostgreSQLCarRevisionRepository implements CarRevisionRepository
SQL;
$content = json_encode([
'production_begin' => $carRevision->productionBegin?->year ?? null,
'production_end' => $carRevision->productionEnd?->year ?? null,
'catalog_price' => $carRevision->catalogPrice?->price ?? null,
'production_begin' => $carRevision->productionBegin->year ?? null,
'production_end' => $carRevision->productionEnd->year ?? null,
'catalog_price' => $carRevision->catalogPrice->price ?? null,
'catalog_price_currency' => $carRevision->catalogPrice?->currency->currency ?? null,
'image_url' => $carRevision->image?->url ?? null,
'image_url' => $carRevision->image->externalPublicUrl ?? null,
'driving_characteristics' => [
'power_kw' => $carRevision->drivingCharacteristics?->power?->kilowatts ?? null,
'acceleration_0_100' => $carRevision->drivingCharacteristics?->acceleration?->secondsFrom0To100 ?? null,
'top_speed_kmh' => $carRevision->drivingCharacteristics?->topSpeed?->kmh ?? null,
'power_kw' => $carRevision->drivingCharacteristics->power->kilowatts ?? null,
'acceleration_0_100' => $carRevision->drivingCharacteristics->acceleration->secondsFrom0To100 ?? null,
'top_speed_kmh' => $carRevision->drivingCharacteristics->topSpeed->kmh ?? null,
'consumption_kwh_100km' => $carRevision->drivingCharacteristics?->consumption?->energyPer100Km->kwh() ?? null,
],
'battery' => [
'usable_capacity_kwh' => $carRevision->battery?->usableCapacity->kwh() ?? null,
'total_capacity_kwh' => $carRevision->battery?->totalCapacity->kwh() ?? null,
'cell_chemistry' => $carRevision->battery?->cellChemistry->value ?? null,
'model' => $carRevision->battery?->model ?? null,
'manufacturer' => $carRevision->battery?->manufacturer ?? null,
'model' => $carRevision->battery->model ?? null,
'manufacturer' => $carRevision->battery->manufacturer ?? null,
],
'charging' => [
'top_charging_speed_kw' => $carRevision->chargingProperties?->topChargingSpeed?->kilowatts ?? null,
'top_charging_speed_kw' => $carRevision->chargingProperties->topChargingSpeed->kilowatts ?? null,
],
'range' => [
'wltp_km' => $carRevision->rangeProperties?->wltp?->range->kilometers ?? null,
@ -125,26 +125,26 @@ final class PostgreSQLCarRevisionRepository implements CarRevisionRepository
SQL;
$content = json_encode([
'production_begin' => $carRevision->productionBegin?->year ?? null,
'production_end' => $carRevision->productionEnd?->year ?? null,
'catalog_price' => $carRevision->catalogPrice?->price ?? null,
'production_begin' => $carRevision->productionBegin->year ?? null,
'production_end' => $carRevision->productionEnd->year ?? null,
'catalog_price' => $carRevision->catalogPrice->price ?? null,
'catalog_price_currency' => $carRevision->catalogPrice?->currency->currency ?? null,
'image_url' => $carRevision->image?->url ?? null,
'image_url' => $carRevision->image->externalPublicUrl ?? null,
'driving_characteristics' => [
'power_kw' => $carRevision->drivingCharacteristics?->power?->kilowatts ?? null,
'acceleration_0_100' => $carRevision->drivingCharacteristics?->acceleration?->secondsFrom0To100 ?? null,
'top_speed_kmh' => $carRevision->drivingCharacteristics?->topSpeed?->kmh ?? null,
'power_kw' => $carRevision->drivingCharacteristics->power->kilowatts ?? null,
'acceleration_0_100' => $carRevision->drivingCharacteristics->acceleration->secondsFrom0To100 ?? null,
'top_speed_kmh' => $carRevision->drivingCharacteristics->topSpeed->kmh ?? null,
'consumption_kwh_100km' => $carRevision->drivingCharacteristics?->consumption?->energyPer100Km->kwh() ?? null,
],
'battery' => [
'usable_capacity_kwh' => $carRevision->battery?->usableCapacity->kwh() ?? null,
'total_capacity_kwh' => $carRevision->battery?->totalCapacity->kwh() ?? null,
'cell_chemistry' => $carRevision->battery?->cellChemistry->value ?? null,
'model' => $carRevision->battery?->model ?? null,
'manufacturer' => $carRevision->battery?->manufacturer ?? null,
'model' => $carRevision->battery->model ?? null,
'manufacturer' => $carRevision->battery->manufacturer ?? null,
],
'charging' => [
'top_charging_speed_kw' => $carRevision->chargingProperties?->topChargingSpeed?->kilowatts ?? null,
'top_charging_speed_kw' => $carRevision->chargingProperties->topChargingSpeed->kilowatts ?? null,
],
'range' => [
'wltp_km' => $carRevision->rangeProperties?->wltp?->range->kilometers ?? null,

View File

@ -0,0 +1,90 @@
<?php
namespace App\Infrastructure\PostgreSQL\Repository\EmbeddingRepository;
use Doctrine\DBAL\Connection;
use App\Domain\Repository\EmbeddingRepository;
use App\Domain\Model\AI\Embedding;
use App\Domain\Model\AI\LargeEmbeddingVector;
use App\Domain\Model\AI\SmallEmbeddingVector;
use App\Domain\Model\Persistence\PersistedEmbedding;
class PostgreSQLEmbeddingRepository implements EmbeddingRepository
{
public function __construct(
private readonly Connection $connection,
) {
}
public function create(Embedding $embedding): PersistedEmbedding
{
$hash = md5($embedding->phrase);
$this->connection->executeStatement(<<<SQL
INSERT INTO embeddings (phrase_hash, phrase, large_embedding_vector, small_embedding_vector)
VALUES (:phrase_hash, :phrase, :large_embedding_vector, :small_embedding_vector)
SQL, [
'phrase_hash' => $hash,
'phrase' => $embedding->phrase,
'large_embedding_vector' => $embedding->largeEmbeddingVector !== null ? '[' . implode(',', $embedding->largeEmbeddingVector->vector->values) . ']' : null,
'small_embedding_vector' => $embedding->smallEmbeddingVector !== null ? '[' . implode(',', $embedding->smallEmbeddingVector->vector->values) . ']' : null,
]);
return new PersistedEmbedding(
$hash,
$embedding,
);
}
public function delete(PersistedEmbedding $persistedEmbedding): void
{
$this->connection->delete('embeddings', ['phrase_hash' => $persistedEmbedding->phraseHash]);
}
public function searchByLargeEmbeddingVector(LargeEmbeddingVector $embeddingVector, int $limit = 10): array
{
$result = $this->connection->executeQuery(<<<SQL
SELECT phrase_hash, phrase, large_embedding_vector, small_embedding_vector, created_at
FROM embeddings
WHERE large_embedding_vector IS NOT NULL
ORDER BY large_embedding_vector <=> :vector
LIMIT :limit
SQL, [
'vector' => '[' . implode(',', $embeddingVector->vector->values) . ']',
'limit' => $limit,
]);
$embeddings = [];
foreach ($result->fetchAllAssociative() as $row) {
$embeddings[] = new PersistedEmbedding(
is_string($row['phrase_hash'] ?? null) ? $row['phrase_hash'] : '',
new Embedding(is_string($row['phrase'] ?? null) ? $row['phrase'] : '', $embeddingVector)
);
}
return $embeddings;
}
public function searchBySmallEmbeddingVector(SmallEmbeddingVector $smallEmbeddingVector, int $limit = 10): array
{
$result = $this->connection->executeQuery(<<<SQL
SELECT phrase_hash, phrase, large_embedding_vector, small_embedding_vector, created_at
FROM embeddings
WHERE small_embedding_vector IS NOT NULL
ORDER BY small_embedding_vector <=> :vector
LIMIT :limit
SQL, [
'vector' => '[' . implode(',', $smallEmbeddingVector->vector->values) . ']',
'limit' => $limit,
]);
$embeddings = [];
foreach ($result->fetchAllAssociative() as $row) {
$embeddings[] = new PersistedEmbedding(
is_string($row['phrase_hash'] ?? null) ? $row['phrase_hash'] : '',
new Embedding(is_string($row['phrase'] ?? null) ? $row['phrase'] : '', null, $smallEmbeddingVector)
);
}
return $embeddings;
}
}

View File

@ -35,6 +35,27 @@
"migrations/.gitignore"
]
},
"php-http/discovery": {
"version": "1.20",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.18",
"ref": "f45b5dd173a27873ab19f5e3180b2f661c21de02"
},
"files": [
"config/packages/http_discovery.yaml"
]
},
"phpstan/phpstan": {
"version": "2.1",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "1.0",
"ref": "5e490cc197fb6bb1ae22e5abbc531ddc633b6767"
}
},
"symfony/asset-mapper": {
"version": "7.3",
"recipe": {