From 48d394028832e0ce240b4ab71ba604927a5ecd67 Mon Sep 17 00:00:00 2001 From: Tim Lappe Date: Mon, 2 Jun 2025 06:37:37 +0200 Subject: [PATCH] [WIP] DDD Refactoring --- migrations/Version20250529155930.php | 57 -- migrations/Version20250530193246.php | 37 - migrations/Version20250601120221.php | 81 ++ src/Application/Commands/AIClientCommand.php | 22 +- src/Application/Commands/LoadFixtures.php | 712 +++++++----------- src/Domain/AI/AIClient.php | 20 +- .../ContentManagement/CarPropertyEmbedder.php | 50 ++ src/Domain/Model/AI/Embedding.php | 8 + src/Domain/Model/Battery/CellChemistry.php | 2 + src/Domain/Model/Brand.php | 9 +- src/Domain/Model/CarModel.php | 10 +- src/Domain/Model/CarProperty.php | 16 + src/Domain/Model/CarPropertyCollection.php | 55 ++ src/Domain/Model/CarPropertyType.php | 101 +++ src/Domain/Model/CarRevision.php | 29 +- src/Domain/Model/DrivingCharacteristics.php | 18 - src/Domain/Model/EmbeddingCollection.php | 23 + src/Domain/Model/Image.php | 9 +- .../Model/Persistence/PersistedBrand.php | 13 - .../Model/Persistence/PersistedCarModel.php | 13 - .../Persistence/PersistedCarRevision.php | 13 - .../Model/Persistence/PersistedEmbedding.php | 14 - src/Domain/Model/RangeProperties.php | 19 - src/Domain/Model/Value/BrandId.php | 29 + src/Domain/Model/Value/CarModelId.php | 29 + src/Domain/Model/Value/CarPropertyId.php | 29 + src/Domain/Model/Value/CarRevisionId.php | 29 + src/Domain/Model/Value/Consumption.php | 1 + src/Domain/Model/Value/EmbeddingId.php | 29 + src/Domain/Repository/BrandRepository.php | 13 +- src/Domain/Repository/CarModelRepository.php | 20 +- .../Repository/CarPropertyRepository.php | 30 + .../Repository/CarRevisionRepository.php | 17 +- src/Domain/Repository/EmbeddingRepository.php | 21 +- src/Domain/Search/Engine.php | 172 +---- src/Domain/Search/TileBuilder.php | 58 ++ .../Search/TileBuilders/CarTileBuilder.php | 67 ++ src/Domain/Search/TileCollection.php | 15 +- .../Search/Tiles/PerformanceOverviewTile.php | 12 - .../Search/Tiles/RangeComparisonTile.php | 12 - .../BrandRepository/ModelMapper.php | 17 +- .../PostgreSQLBrandRepository.php | 87 +-- .../CarModelRepository/ModelMapper.php | 24 +- .../PostgreSQLCarModelRepository.php | 97 +-- .../PostgreSQLCarPropertyRepository.php | 121 +++ .../CarRevisionRepository/ModelMapper.php | 127 +--- .../PostgreSQLCarRevisionRepository.php | 141 +--- .../PostgreSQLEmbeddingRepository.php | 170 +++-- templates/home/index.html.twig | 2 +- 49 files changed, 1422 insertions(+), 1278 deletions(-) delete mode 100644 migrations/Version20250529155930.php delete mode 100644 migrations/Version20250530193246.php create mode 100644 migrations/Version20250601120221.php create mode 100644 src/Domain/ContentManagement/CarPropertyEmbedder.php create mode 100644 src/Domain/Model/CarProperty.php create mode 100644 src/Domain/Model/CarPropertyCollection.php create mode 100644 src/Domain/Model/CarPropertyType.php delete mode 100644 src/Domain/Model/DrivingCharacteristics.php create mode 100644 src/Domain/Model/EmbeddingCollection.php delete mode 100644 src/Domain/Model/Persistence/PersistedBrand.php delete mode 100644 src/Domain/Model/Persistence/PersistedCarModel.php delete mode 100644 src/Domain/Model/Persistence/PersistedCarRevision.php delete mode 100644 src/Domain/Model/Persistence/PersistedEmbedding.php delete mode 100644 src/Domain/Model/RangeProperties.php create mode 100644 src/Domain/Model/Value/BrandId.php create mode 100644 src/Domain/Model/Value/CarModelId.php create mode 100644 src/Domain/Model/Value/CarPropertyId.php create mode 100644 src/Domain/Model/Value/CarRevisionId.php create mode 100644 src/Domain/Model/Value/EmbeddingId.php create mode 100644 src/Domain/Repository/CarPropertyRepository.php create mode 100644 src/Domain/Search/TileBuilder.php create mode 100644 src/Domain/Search/TileBuilders/CarTileBuilder.php delete mode 100644 src/Domain/Search/Tiles/PerformanceOverviewTile.php delete mode 100644 src/Domain/Search/Tiles/RangeComparisonTile.php create mode 100644 src/Infrastructure/PostgreSQL/Repository/CarPropertyRepository/PostgreSQLCarPropertyRepository.php diff --git a/migrations/Version20250529155930.php b/migrations/Version20250529155930.php deleted file mode 100644 index 63438df..0000000 --- a/migrations/Version20250529155930.php +++ /dev/null @@ -1,57 +0,0 @@ -addSql(<<addSql(<<addSql(<<addSql('DROP TABLE car_revisions'); - $this->addSql('DROP TABLE car_models'); - $this->addSql('DROP TABLE brands'); - } -} diff --git a/migrations/Version20250530193246.php b/migrations/Version20250530193246.php deleted file mode 100644 index db1368a..0000000 --- a/migrations/Version20250530193246.php +++ /dev/null @@ -1,37 +0,0 @@ -addSql(<<addSql('DROP TABLE embeddings'); - } -} diff --git a/migrations/Version20250601120221.php b/migrations/Version20250601120221.php new file mode 100644 index 0000000..7ad175f --- /dev/null +++ b/migrations/Version20250601120221.php @@ -0,0 +1,81 @@ +addSql('CREATE EXTENSION IF NOT EXISTS vector'); + + // Create brands table + $this->addSql('CREATE TABLE brands ( + id VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + PRIMARY KEY(id) + )'); + + // Create car_models table + $this->addSql('CREATE TABLE car_models ( + id VARCHAR(255) NOT NULL, + brand_id VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + PRIMARY KEY(id) + )'); + + // Create car_revisions table + $this->addSql('CREATE TABLE car_revisions ( + id VARCHAR(255) NOT NULL, + car_model_id VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + PRIMARY KEY(id) + )'); + + // Create car_properties table + $this->addSql('CREATE TABLE car_properties ( + id VARCHAR(255) NOT NULL, + car_revision_id VARCHAR(255) NOT NULL, + type VARCHAR(255) NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY(id) + )'); + + // Create embeddings table + $this->addSql('CREATE TABLE embeddings ( + id VARCHAR(255) NOT NULL, + phrase_hash VARCHAR(255) NOT NULL, + phrase VARCHAR(255) NOT NULL, + large_embedding_vector VECTOR(3072) NOT NULL, + small_embedding_vector VECTOR(1536) NOT NULL, + PRIMARY KEY(id) + )'); + + // Add foreign key constraints + $this->addSql('ALTER TABLE car_models ADD CONSTRAINT FK_car_models_brand_id FOREIGN KEY (brand_id) REFERENCES brands (id)'); + $this->addSql('ALTER TABLE car_revisions ADD CONSTRAINT FK_car_revisions_car_model_id FOREIGN KEY (car_model_id) REFERENCES car_models (id)'); + $this->addSql('ALTER TABLE car_properties ADD CONSTRAINT FK_car_properties_car_revision_id FOREIGN KEY (car_revision_id) REFERENCES car_revisions (id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE car_properties'); + $this->addSql('DROP TABLE car_revisions'); + $this->addSql('DROP TABLE car_models'); + $this->addSql('DROP TABLE embeddings'); + $this->addSql('DROP TABLE brands'); + } +} diff --git a/src/Application/Commands/AIClientCommand.php b/src/Application/Commands/AIClientCommand.php index 4ce73f2..50aa3ce 100644 --- a/src/Application/Commands/AIClientCommand.php +++ b/src/Application/Commands/AIClientCommand.php @@ -6,8 +6,7 @@ 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 App\Domain\Model\Value\EmbeddingId; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -41,12 +40,12 @@ class AIClientCommand extends Command $text = is_string($textArg) ? $textArg : ''; if ($input->getOption('embed')) { - $persistedEmbedding = $this->embedText($text); - $output->writeln($persistedEmbedding->phraseHash); + $embedding = $this->embedText($text); + $output->writeln($embedding->phrase); } 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); + $results = $this->embeddingRepository->searchByLargeEmbeddingVector($this->aiClient->embedTextLarge($text)); + foreach ($results->array() as $result) { + $output->writeln($result->phrase); } } else { $output->writeln($this->aiClient->generateText($text)); @@ -55,11 +54,12 @@ class AIClientCommand extends Command return Command::SUCCESS; } - private function embedText(string $text): PersistedEmbedding + private function embedText(string $text): Embedding { - $embedding = $this->aiClient->embedText($text); - $vector = new Vector($embedding); + $embeddingVector = $this->aiClient->embedTextLarge($text); + $embedding = new Embedding(EmbeddingId::generate(), $text, $embeddingVector); + $this->embeddingRepository->save($embedding); - return $this->embeddingRepository->create(new Embedding($text, new LargeEmbeddingVector($vector))); + return $embedding; } } \ No newline at end of file diff --git a/src/Application/Commands/LoadFixtures.php b/src/Application/Commands/LoadFixtures.php index b3658de..36c721e 100644 --- a/src/Application/Commands/LoadFixtures.php +++ b/src/Application/Commands/LoadFixtures.php @@ -2,20 +2,21 @@ namespace App\Application\Commands; +use App\Domain\ContentManagement\CarPropertyEmbedder; use App\Domain\Model\Brand; use App\Domain\Model\CarModel; use App\Domain\Model\CarRevision; -use App\Domain\Model\DrivingCharacteristics; use App\Domain\Model\Image; use App\Domain\Model\Value\Date; use App\Domain\Model\Value\Price; use App\Domain\Model\Value\Currency; -use App\Domain\Model\Battery\BatteryProperties; +use App\Domain\Model\Value\BrandId; +use App\Domain\Model\Value\CarModelId; +use App\Domain\Model\Value\CarRevisionId; +use App\Domain\Model\Value\CarPropertyId; use App\Domain\Model\Battery\CellChemistry; -use App\Domain\Model\Charging\ChargingProperties; -use App\Domain\Model\RangeProperties; -use App\Domain\Model\Range\WltpRange; -use App\Domain\Model\Range\NefzRange; +use App\Domain\Model\CarProperty; +use App\Domain\Model\CarPropertyType; use App\Domain\Model\Value\Energy; use App\Domain\Model\Value\Power; use App\Domain\Model\Value\Acceleration; @@ -25,9 +26,11 @@ use App\Domain\Model\Value\Range; use App\Domain\Repository\BrandRepository; use App\Domain\Repository\CarModelRepository; use App\Domain\Repository\CarRevisionRepository; +use App\Domain\Repository\CarPropertyRepository; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; @@ -37,485 +40,348 @@ use Symfony\Component\Console\Style\SymfonyStyle; )] class LoadFixtures extends Command { - /** @var array */ - private array $brandIds = []; - /** @var array */ - private array $carModelIds = []; + /** @var array */ + private array $brands = []; + /** @var array */ + private array $carModels = []; public function __construct( private readonly BrandRepository $brandRepository, private readonly CarModelRepository $carModelRepository, private readonly CarRevisionRepository $carRevisionRepository, + private readonly CarPropertyRepository $carPropertyRepository, + private readonly CarPropertyEmbedder $carPropertyEmbedder, ) { parent::__construct(); } + protected function configure(): void + { + $this->addOption('delete', null, InputOption::VALUE_NONE, 'Delete all existing data before loading fixtures'); + } + protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); + + if ($input->getOption('delete') !== null) { + $io->section('Deleting all existing data'); + $this->carPropertyRepository->deleteAll(); + $this->carRevisionRepository->deleteAll(); + $this->carModelRepository->deleteAll(); + $this->brandRepository->deleteAll(); + } + $io->title('Loading EV Wiki Fixtures'); + $fixtures = $this->getFixtures(); + + // Extract unique brands + $brandNames = array_unique(array_column($fixtures, 'brand')); // Load brands - $brands = $this->getFixtureBrands(); $io->section('Loading Brands'); - $io->progressStart(count($brands)); + $io->progressStart(count($brandNames)); - foreach ($brands as $brand) { - $persistedBrand = $this->brandRepository->create($brand); - $this->brandIds[$brand->name] = $persistedBrand->id; + foreach ($brandNames as $brandName) { + $brand = new Brand(BrandId::generate(), $brandName); + $this->brandRepository->save($brand); + $this->brands[$brandName] = $brand; $io->progressAdvance(); } $io->progressFinish(); - $io->success(sprintf('Successfully loaded %d brands', count($brands))); + $io->success(sprintf('Successfully loaded %d brands', count($brandNames))); + + // Extract unique models + $models = []; + foreach ($fixtures as $brandFixture) { + foreach ($brandFixture['models'] as $modelFixture) { + $models[$modelFixture['model']] = $brandFixture['brand']; + } + } // Load car models - $carModels = $this->getFixtureCarModels(); $io->section('Loading Car Models'); - $io->progressStart(count($carModels)); + $io->progressStart(count($models)); - foreach ($carModels as $carModelData) { - $model = $carModelData['model']; - $brandName = $carModelData['brand']; - - $persistedCarModel = $this->carModelRepository->create( - $model, - $this->brandIds[$brandName] + foreach ($models as $modelName => $brandName) { + $carModel = new CarModel( + CarModelId::generate(), + $this->brands[$brandName]->brandId, + $modelName ); - $this->carModelIds[$model->name] = $persistedCarModel->id; + + $this->carModelRepository->save($carModel); + $this->carModels[$modelName] = $carModel; $io->progressAdvance(); } $io->progressFinish(); - $io->success(sprintf('Successfully loaded %d car models', count($carModels))); + $io->success(sprintf('Successfully loaded %d car models', count($models))); - // Load car revisions - $carRevisions = $this->getFixtureCarRevisions(); - $io->section('Loading Car Revisions'); - $io->progressStart(count($carRevisions)); + // Count total revisions for progress bar + $totalRevisions = 0; + foreach ($fixtures as $brandFixture) { + foreach ($brandFixture['models'] as $modelFixture) { + $totalRevisions += count($modelFixture['revisions']); + } + } - foreach ($carRevisions as $carRevisionData) { - $revision = $carRevisionData['revision']; - $modelName = $carRevisionData['model']; + // Load car revisions and properties + $io->section('Loading Car Revisions and Properties'); + $io->progressStart($totalRevisions); + + foreach ($fixtures as $brandFixture) { + $brand = $this->brands[$brandFixture['brand']]; - $this->carRevisionRepository->create( - $revision, - $this->carModelIds[$modelName] - ); - $io->progressAdvance(); + foreach ($brandFixture['models'] as $modelFixture) { + $carModel = $this->carModels[$modelFixture['model']]; + + foreach ($modelFixture['revisions'] as $revisionFixture) { + // Create car revision + $carRevision = new CarRevision( + CarRevisionId::generate(), + $carModel->carModelId, + $revisionFixture['revision'] + ); + + $this->carRevisionRepository->save($carRevision); + + // Create properties + foreach ($revisionFixture['properties'] as $propertyData) { + $property = new CarProperty( + CarPropertyId::generate(), + $carRevision->carRevisionId, + $propertyData['type'], + $propertyData['value'] + ); + $this->carPropertyRepository->save($property); + $this->carPropertyEmbedder->createPersistedEmbedding($property, $carRevision, $brand); + } + + $io->progressAdvance(); + } + } } $io->progressFinish(); - $io->success(sprintf('Successfully loaded %d car revisions', count($carRevisions))); - + $io->success(sprintf('Successfully loaded %d car revisions with properties', $totalRevisions)); $io->success('All fixtures loaded successfully!'); return Command::SUCCESS; } /** - * @return Brand[] + * @return array}>}>}> */ - private function getFixtureBrands(): array + private function getFixtures(): array { return [ - new Brand( - name: 'Tesla', - logo: 'https://logo.clearbit.com/tesla.com', - description: 'American electric vehicle and clean energy company founded by Elon Musk', - foundedYear: 2003, - headquarters: 'Austin, Texas, USA', - website: 'https://tesla.com', - ), - new Brand( - name: 'BMW', - logo: 'https://logo.clearbit.com/bmw.com', - description: 'German multinational corporation producing luxury vehicles with strong EV lineup', - foundedYear: 1916, - headquarters: 'Munich, Germany', - website: 'https://bmw.com', - ), - new Brand( - name: 'Audi', - logo: 'https://logo.clearbit.com/audi.com', - description: 'German automotive manufacturer of luxury vehicles with e-tron electric series', - foundedYear: 1909, - headquarters: 'Ingolstadt, Germany', - website: 'https://audi.com', - ), - new Brand( - name: 'Mercedes-Benz', - logo: 'https://logo.clearbit.com/mercedes-benz.com', - description: 'German luxury automotive brand with EQS, EQC and other electric models', - foundedYear: 1926, - headquarters: 'Stuttgart, Germany', - website: 'https://mercedes-benz.com', - ), - new Brand( - name: 'Volkswagen', - logo: 'https://logo.clearbit.com/vw.com', - description: 'German motor vehicle manufacturer with ID series electric vehicles', - foundedYear: 1937, - headquarters: 'Wolfsburg, Germany', - website: 'https://vw.com', - ), - new Brand( - name: 'Porsche', - logo: 'https://logo.clearbit.com/porsche.com', - description: 'German sports car manufacturer with Taycan electric sports cars', - foundedYear: 1931, - headquarters: 'Stuttgart, Germany', - website: 'https://porsche.com', - ), - new Brand( - name: 'Lucid Motors', - logo: 'https://logo.clearbit.com/lucidmotors.com', - description: 'American electric vehicle manufacturer focused on luxury sedans', - foundedYear: 2007, - headquarters: 'Newark, California, USA', - website: 'https://lucidmotors.com', - ), - new Brand( - name: 'Rivian', - logo: 'https://logo.clearbit.com/rivian.com', - description: 'American electric vehicle manufacturer focusing on electric trucks and vans', - foundedYear: 2009, - headquarters: 'Irvine, California, USA', - website: 'https://rivian.com', - ), - new Brand( - name: 'NIO', - logo: 'https://logo.clearbit.com/nio.com', - description: 'Chinese electric vehicle manufacturer with innovative battery swapping technology', - foundedYear: 2014, - headquarters: 'Shanghai, China', - website: 'https://nio.com', - ), - new Brand( - name: 'BYD', - logo: 'https://logo.clearbit.com/byd.com', - description: 'Chinese electric vehicle and battery manufacturer, world leader in EV sales', - foundedYear: 1995, - headquarters: 'Shenzhen, China', - website: 'https://byd.com', - ) - ]; - } - - /** - * @return array - */ - private function getFixtureCarModels(): array - { - return [ - // Tesla Models [ 'brand' => 'Tesla', - 'model' => new CarModel('Model S'), - ], - [ - 'brand' => 'Tesla', - 'model' => new CarModel('Model 3'), - ], - [ - 'brand' => 'Tesla', - 'model' => new CarModel('Model X'), - ], - [ - 'brand' => 'Tesla', - 'model' => new CarModel('Model Y'), - ], - - // BMW Models - [ - 'brand' => 'BMW', - 'model' => new CarModel('iX'), + 'models' => [ + [ + 'model' => 'Model S', + 'revisions' => [ + [ + 'revision' => 'Plaid', + 'properties' => [ + ['type' => CarPropertyType::PRODUCTION_BEGIN, 'value' => new Date(1, 1, 2021)], + ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_POWER, 'value' => new Power(750)], + ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_ACCELERATION, 'value' => new Acceleration(2.1)], + ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_TOP_SPEED, 'value' => new Speed(322)], + ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_CONSUMPTION, 'value' => new Consumption(new Energy(19.3))], + ['type' => CarPropertyType::BATTERY_USABLE_CAPACITY, 'value' => new Energy(95.0)], + ['type' => CarPropertyType::BATTERY_TOTAL_CAPACITY, 'value' => new Energy(100.0)], + ['type' => CarPropertyType::BATTERY_CELL_CHEMISTRY, 'value' => CellChemistry::LithiumNickelManganeseOxide], + ['type' => CarPropertyType::BATTERY_MODEL, 'value' => '4680'], + ['type' => CarPropertyType::BATTERY_MANUFACTURER, 'value' => 'Tesla'], + ['type' => CarPropertyType::CHARGING_TOP_CHARGING_SPEED, 'value' => new Power(250)], + ['type' => CarPropertyType::RANGE_WLTP, 'value' => new Range(628)], + ['type' => CarPropertyType::RANGE_NEFZ, 'value' => new Range(652)], + ['type' => CarPropertyType::CATALOG_PRICE, 'value' => new Price(129990, Currency::euro())], + ['type' => CarPropertyType::IMAGE_EXTERNAL_PUBLIC_URL, 'value' => new Image(externalPublicUrl: 'https://digitalassets.tesla.com/tesla-contents/image/upload/f_auto,q_auto/Model-S-Main-Hero-Desktop-LHD.jpg')] + ] + ] + ] + ], + [ + 'model' => 'Model 3', + 'revisions' => [ + [ + 'revision' => 'Long Range', + 'properties' => [ + ['type' => CarPropertyType::PRODUCTION_BEGIN, 'value' => new Date(1, 1, 2020)], + ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_POWER, 'value' => new Power(366)], + ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_ACCELERATION, 'value' => new Acceleration(4.4)], + ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_TOP_SPEED, 'value' => new Speed(233)], + ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_CONSUMPTION, 'value' => new Consumption(new Energy(14.9))], + ['type' => CarPropertyType::BATTERY_USABLE_CAPACITY, 'value' => new Energy(75.0)], + ['type' => CarPropertyType::BATTERY_TOTAL_CAPACITY, 'value' => new Energy(82.0)], + ['type' => CarPropertyType::BATTERY_CELL_CHEMISTRY, 'value' => CellChemistry::LithiumNickelManganeseOxide], + ['type' => CarPropertyType::BATTERY_MODEL, 'value' => '2170'], + ['type' => CarPropertyType::BATTERY_MANUFACTURER, 'value' => 'Tesla'], + ['type' => CarPropertyType::CHARGING_TOP_CHARGING_SPEED, 'value' => new Power(250)], + ['type' => CarPropertyType::RANGE_WLTP, 'value' => new Range(602)], + ['type' => CarPropertyType::RANGE_NEFZ, 'value' => new Range(614)], + ['type' => CarPropertyType::CATALOG_PRICE, 'value' => new Price(49990, Currency::euro())], + ['type' => CarPropertyType::IMAGE_EXTERNAL_PUBLIC_URL, 'value' => new Image(externalPublicUrl: 'https://digitalassets.tesla.com/tesla-contents/image/upload/f_auto,q_auto/Model-3-Main-Hero-Desktop-LHD.jpg')] + ] + ] + ] + ] + ] ], [ 'brand' => 'BMW', - 'model' => new CarModel('i4'), - ], - [ - 'brand' => 'BMW', - 'model' => new CarModel('iX3'), - ], - - // Audi Models - [ - 'brand' => 'Audi', - 'model' => new CarModel('e-tron GT'), + 'models' => [ + [ + 'model' => 'iX', + 'revisions' => [ + [ + 'revision' => 'xDrive50', + 'properties' => [ + ['type' => CarPropertyType::PRODUCTION_BEGIN, 'value' => new Date(1, 1, 2021)], + ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_POWER, 'value' => new Power(385)], + ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_ACCELERATION, 'value' => new Acceleration(4.6)], + ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_TOP_SPEED, 'value' => new Speed(200)], + ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_CONSUMPTION, 'value' => new Consumption(new Energy(19.8))], + ['type' => CarPropertyType::BATTERY_USABLE_CAPACITY, 'value' => new Energy(71.2)], + ['type' => CarPropertyType::BATTERY_TOTAL_CAPACITY, 'value' => new Energy(76.6)], + ['type' => CarPropertyType::BATTERY_CELL_CHEMISTRY, 'value' => CellChemistry::LithiumNickelManganeseOxide], + ['type' => CarPropertyType::BATTERY_MODEL, 'value' => 'BMW Gen5'], + ['type' => CarPropertyType::BATTERY_MANUFACTURER, 'value' => 'CATL'], + ['type' => CarPropertyType::CHARGING_TOP_CHARGING_SPEED, 'value' => new Power(195)], + ['type' => CarPropertyType::RANGE_WLTP, 'value' => new Range(630)], + ['type' => CarPropertyType::RANGE_NEFZ, 'value' => new Range(680)], + ['type' => CarPropertyType::CATALOG_PRICE, 'value' => new Price(77300, Currency::euro())], + ['type' => CarPropertyType::IMAGE_EXTERNAL_PUBLIC_URL, 'value' => new Image(externalPublicUrl: 'https://www.bmw.de/content/dam/bmw/common/all-models/x-series/ix/2021/highlights/bmw-ix-sp-desktop.jpg')] + ] + ] + ] + ] + ] ], [ 'brand' => 'Audi', - 'model' => new CarModel('Q4 e-tron'), - ], - [ - 'brand' => 'Audi', - 'model' => new CarModel('e-tron'), - ], - - // Mercedes-Benz Models - [ - 'brand' => 'Mercedes-Benz', - 'model' => new CarModel('EQS'), + 'models' => [ + [ + 'model' => 'e-tron GT', + 'revisions' => [ + [ + 'revision' => 'RS', + 'properties' => [ + ['type' => CarPropertyType::PRODUCTION_BEGIN, 'value' => new Date(1, 1, 2021)], + ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_POWER, 'value' => new Power(475)], + ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_ACCELERATION, 'value' => new Acceleration(3.3)], + ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_TOP_SPEED, 'value' => new Speed(250)], + ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_CONSUMPTION, 'value' => new Consumption(new Energy(19.6))], + ['type' => CarPropertyType::BATTERY_USABLE_CAPACITY, 'value' => new Energy(83.7)], + ['type' => CarPropertyType::BATTERY_TOTAL_CAPACITY, 'value' => new Energy(93.4)], + ['type' => CarPropertyType::BATTERY_CELL_CHEMISTRY, 'value' => CellChemistry::LithiumNickelManganeseOxide], + ['type' => CarPropertyType::BATTERY_MODEL, 'value' => 'PPE Platform'], + ['type' => CarPropertyType::BATTERY_MANUFACTURER, 'value' => 'LG Energy Solution'], + ['type' => CarPropertyType::CHARGING_TOP_CHARGING_SPEED, 'value' => new Power(270)], + ['type' => CarPropertyType::RANGE_WLTP, 'value' => new Range(472)], + ['type' => CarPropertyType::RANGE_NEFZ, 'value' => new Range(487)], + ['type' => CarPropertyType::CATALOG_PRICE, 'value' => new Price(142900, Currency::euro())], + ['type' => CarPropertyType::IMAGE_EXTERNAL_PUBLIC_URL, 'value' => new Image(externalPublicUrl: 'https://www.audi.de/content/dam/nemo/models/e-tron-gt/my-2021/1920x1080-gallery/1920x1080_AudiRS_e-tron_GT_19.jpg')] + ] + ] + ] + ] + ] ], [ 'brand' => 'Mercedes-Benz', - 'model' => new CarModel('EQC'), - ], - [ - 'brand' => 'Mercedes-Benz', - 'model' => new CarModel('EQA'), - ], - - // Volkswagen Models - [ - 'brand' => 'Volkswagen', - 'model' => new CarModel('ID.4'), + 'models' => [ + [ + 'model' => 'EQS', + 'revisions' => [ + [ + 'revision' => '450+', + 'properties' => [ + ['type' => CarPropertyType::PRODUCTION_BEGIN, 'value' => new Date(1, 1, 2021)], + ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_POWER, 'value' => new Power(245)], + ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_ACCELERATION, 'value' => new Acceleration(6.2)], + ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_TOP_SPEED, 'value' => new Speed(210)], + ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_CONSUMPTION, 'value' => new Consumption(new Energy(15.7))], + ['type' => CarPropertyType::BATTERY_USABLE_CAPACITY, 'value' => new Energy(90.0)], + ['type' => CarPropertyType::BATTERY_TOTAL_CAPACITY, 'value' => new Energy(107.8)], + ['type' => CarPropertyType::BATTERY_CELL_CHEMISTRY, 'value' => CellChemistry::LithiumNickelManganeseOxide], + ['type' => CarPropertyType::BATTERY_MODEL, 'value' => 'EVA Platform'], + ['type' => CarPropertyType::BATTERY_MANUFACTURER, 'value' => 'CATL'], + ['type' => CarPropertyType::CHARGING_TOP_CHARGING_SPEED, 'value' => new Power(200)], + ['type' => CarPropertyType::RANGE_WLTP, 'value' => new Range(756)], + ['type' => CarPropertyType::RANGE_NEFZ, 'value' => new Range(770)], + ['type' => CarPropertyType::CATALOG_PRICE, 'value' => new Price(106374, Currency::euro())], + ['type' => CarPropertyType::IMAGE_EXTERNAL_PUBLIC_URL, 'value' => new Image(externalPublicUrl: 'https://www.mercedes-benz.de/content/germany/de/mercedes-benz/vehicles/passenger-cars/eqs/sedan/overview/_jcr_content/root/responsivegrid/simple_stage.component.damq2.3318859008536.jpg')] + ] + ] + ] + ] + ] ], [ 'brand' => 'Volkswagen', - 'model' => new CarModel('ID.3'), - ], - [ - 'brand' => 'Volkswagen', - 'model' => new CarModel('ID.Buzz'), - ], - - // Porsche Models - [ - 'brand' => 'Porsche', - 'model' => new CarModel('Taycan'), + 'models' => [ + [ + 'model' => 'ID.4', + 'revisions' => [ + [ + 'revision' => 'Pro', + 'properties' => [ + ['type' => CarPropertyType::PRODUCTION_BEGIN, 'value' => new Date(1, 1, 2020)], + ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_POWER, 'value' => new Power(150)], + ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_ACCELERATION, 'value' => new Acceleration(8.5)], + ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_TOP_SPEED, 'value' => new Speed(160)], + ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_CONSUMPTION, 'value' => new Consumption(new Energy(16.3))], + ['type' => CarPropertyType::BATTERY_USABLE_CAPACITY, 'value' => new Energy(77.0)], + ['type' => CarPropertyType::BATTERY_TOTAL_CAPACITY, 'value' => new Energy(82.0)], + ['type' => CarPropertyType::BATTERY_CELL_CHEMISTRY, 'value' => CellChemistry::LithiumNickelManganeseOxide], + ['type' => CarPropertyType::BATTERY_MODEL, 'value' => 'MEB Platform'], + ['type' => CarPropertyType::BATTERY_MANUFACTURER, 'value' => 'LG Energy Solution'], + ['type' => CarPropertyType::CHARGING_TOP_CHARGING_SPEED, 'value' => new Power(125)], + ['type' => CarPropertyType::RANGE_WLTP, 'value' => new Range(520)], + ['type' => CarPropertyType::RANGE_NEFZ, 'value' => new Range(549)], + ['type' => CarPropertyType::CATALOG_PRICE, 'value' => new Price(51515, Currency::euro())], + ['type' => CarPropertyType::IMAGE_EXTERNAL_PUBLIC_URL, 'value' => new Image(externalPublicUrl: 'https://www.volkswagen.de/content/dam/vw-ngw/vw_pkw/importers/de/models/id-4/gallery/id4-gallery-exterior-01-16x9.jpg')] + ] + ] + ] + ] + ] ], [ 'brand' => 'Porsche', - 'model' => new CarModel('Macan Electric'), - ], - ]; - } - - /** - * @return array - */ - private function getFixtureCarRevisions(): array - { - return [ - // Tesla Model S Plaid - [ - 'model' => 'Model S', - 'revision' => new CarRevision( - name: 'Plaid', - productionBegin: new Date(1, 1, 2021), - drivingCharacteristics: new DrivingCharacteristics( - power: new Power(750), - acceleration: new Acceleration(2.1), - topSpeed: new Speed(322), - consumption: new Consumption(new Energy(19.3)) - ), - battery: new BatteryProperties( - usableCapacity: new Energy(95.0), - totalCapacity: new Energy(100.0), - cellChemistry: CellChemistry::LithiumNickelManganeseOxide, - model: '4680', - manufacturer: 'Tesla' - ), - chargingProperties: new ChargingProperties( - topChargingSpeed: new Power(250) - ), - rangeProperties: new RangeProperties( - wltp: new WltpRange(new Range(628)), - nefz: new NefzRange(new Range(652)) - ), - catalogPrice: new Price(129990, Currency::euro()), - image: new Image('https://digitalassets.tesla.com/tesla-contents/image/upload/f_auto,q_auto/Model-S-Main-Hero-Desktop-LHD.jpg') - ), - ], - - // Tesla Model 3 Long Range - [ - 'model' => 'Model 3', - 'revision' => new CarRevision( - name: 'Long Range', - productionBegin: new Date(1, 1, 2020), - drivingCharacteristics: new DrivingCharacteristics( - power: new Power(366), - acceleration: new Acceleration(4.4), - topSpeed: new Speed(233), - consumption: new Consumption(new Energy(14.9)) - ), - battery: new BatteryProperties( - usableCapacity: new Energy(75.0), - totalCapacity: new Energy(82.0), - cellChemistry: CellChemistry::LithiumNickelManganeseOxide, - model: '2170', - manufacturer: 'Tesla' - ), - chargingProperties: new ChargingProperties( - topChargingSpeed: new Power(250) - ), - rangeProperties: new RangeProperties( - wltp: new WltpRange(new Range(602)), - nefz: new NefzRange(new Range(614)) - ), - catalogPrice: new Price(49990, Currency::euro()), - image: new Image('https://digitalassets.tesla.com/tesla-contents/image/upload/f_auto,q_auto/Model-3-Main-Hero-Desktop-LHD.jpg') - ), - ], - - // BMW iX xDrive50 - [ - 'model' => 'iX', - 'revision' => new CarRevision( - name: 'xDrive50', - productionBegin: new Date(1, 1, 2021), - drivingCharacteristics: new DrivingCharacteristics( - power: new Power(385), - acceleration: new Acceleration(4.6), - topSpeed: new Speed(200), - consumption: new Consumption(new Energy(19.8)) - ), - battery: new BatteryProperties( - usableCapacity: new Energy(71.2), - totalCapacity: new Energy(76.6), - cellChemistry: CellChemistry::LithiumNickelManganeseOxide, - model: 'BMW Gen5', - manufacturer: 'CATL' - ), - chargingProperties: new ChargingProperties( - topChargingSpeed: new Power(195) - ), - rangeProperties: new RangeProperties( - wltp: new WltpRange(new Range(630)), - nefz: new NefzRange(new Range(680)) - ), - catalogPrice: new Price(77300, Currency::euro()), - image: new Image('https://www.bmw.de/content/dam/bmw/common/all-models/x-series/ix/2021/highlights/bmw-ix-sp-desktop.jpg') - ), - ], - - // Audi e-tron GT RS - [ - 'model' => 'e-tron GT', - 'revision' => new CarRevision( - name: 'RS', - productionBegin: new Date(1, 1, 2021), - drivingCharacteristics: new DrivingCharacteristics( - power: new Power(475), - acceleration: new Acceleration(3.3), - topSpeed: new Speed(250), - consumption: new Consumption(new Energy(19.6)) - ), - battery: new BatteryProperties( - usableCapacity: new Energy(83.7), - totalCapacity: new Energy(93.4), - cellChemistry: CellChemistry::LithiumNickelManganeseOxide, - model: 'PPE Platform', - manufacturer: 'LG Energy Solution' - ), - chargingProperties: new ChargingProperties( - topChargingSpeed: new Power(270) - ), - rangeProperties: new RangeProperties( - wltp: new WltpRange(new Range(472)), - nefz: new NefzRange(new Range(487)) - ), - catalogPrice: new Price(142900, Currency::euro()), - image: new Image('https://www.audi.de/content/dam/nemo/models/e-tron-gt/my-2021/1920x1080-gallery/1920x1080_AudiRS_e-tron_GT_19.jpg') - ), - ], - - // Mercedes EQS 450+ - [ - 'model' => 'EQS', - 'revision' => new CarRevision( - name: '450+', - productionBegin: new Date(1, 1, 2021), - drivingCharacteristics: new DrivingCharacteristics( - power: new Power(245), - acceleration: new Acceleration(6.2), - topSpeed: new Speed(210), - consumption: new Consumption(new Energy(15.7)) - ), - battery: new BatteryProperties( - usableCapacity: new Energy(90.0), - totalCapacity: new Energy(107.8), - cellChemistry: CellChemistry::LithiumNickelManganeseOxide, - model: 'EVA Platform', - manufacturer: 'CATL' - ), - chargingProperties: new ChargingProperties( - topChargingSpeed: new Power(200) - ), - rangeProperties: new RangeProperties( - wltp: new WltpRange(new Range(756)), - nefz: new NefzRange(new Range(770)) - ), - catalogPrice: new Price(106374, Currency::euro()), - image: new Image('https://www.mercedes-benz.de/content/germany/de/mercedes-benz/vehicles/passenger-cars/eqs/sedan/overview/_jcr_content/root/responsivegrid/simple_stage.component.damq2.3318859008536.jpg') - ), - ], - - // Volkswagen ID.4 Pro - [ - 'model' => 'ID.4', - 'revision' => new CarRevision( - name: 'Pro', - productionBegin: new Date(1, 1, 2020), - drivingCharacteristics: new DrivingCharacteristics( - power: new Power(150), - acceleration: new Acceleration(8.5), - topSpeed: new Speed(160), - consumption: new Consumption(new Energy(16.3)) - ), - battery: new BatteryProperties( - usableCapacity: new Energy(77.0), - totalCapacity: new Energy(82.0), - cellChemistry: CellChemistry::LithiumNickelManganeseOxide, - model: 'MEB Platform', - manufacturer: 'LG Energy Solution' - ), - chargingProperties: new ChargingProperties( - topChargingSpeed: new Power(125) - ), - rangeProperties: new RangeProperties( - wltp: new WltpRange(new Range(520)), - nefz: new NefzRange(new Range(549)) - ), - catalogPrice: new Price(51515, Currency::euro()), - image: new Image('https://www.volkswagen.de/content/dam/vw-ngw/vw_pkw/importers/de/models/id-4/gallery/id4-gallery-exterior-01-16x9.jpg') - ), - ], - - // Porsche Taycan Turbo S - [ - 'model' => 'Taycan', - 'revision' => new CarRevision( - name: 'Turbo S', - productionBegin: new Date(1, 1, 2019), - drivingCharacteristics: new DrivingCharacteristics( - power: new Power(560), - acceleration: new Acceleration(2.8), - topSpeed: new Speed(260), - consumption: new Consumption(new Energy(23.7)) - ), - battery: new BatteryProperties( - usableCapacity: new Energy(83.7), - totalCapacity: new Energy(93.4), - cellChemistry: CellChemistry::LithiumNickelManganeseOxide, - model: 'J1 Platform', - manufacturer: 'LG Energy Solution' - ), - chargingProperties: new ChargingProperties( - topChargingSpeed: new Power(270) - ), - rangeProperties: new RangeProperties( - wltp: new WltpRange(new Range(440)), - nefz: new NefzRange(new Range(452)) - ), - catalogPrice: new Price(185456, Currency::euro()), - image: new Image('https://www.porsche.com/germany/models/taycan/taycan-models/turbo-s/_jcr_content/par/twocolumnlayout/par_left/image.transform.porsche-model-desktop-xl.jpg') - ), - ], + 'models' => [ + [ + 'model' => 'Taycan', + 'revisions' => [ + [ + 'revision' => 'Turbo S', + 'properties' => [ + ['type' => CarPropertyType::PRODUCTION_BEGIN, 'value' => new Date(1, 1, 2019)], + ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_POWER, 'value' => new Power(560)], + ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_ACCELERATION, 'value' => new Acceleration(2.8)], + ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_TOP_SPEED, 'value' => new Speed(260)], + ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_CONSUMPTION, 'value' => new Consumption(new Energy(23.7))], + ['type' => CarPropertyType::BATTERY_USABLE_CAPACITY, 'value' => new Energy(83.7)], + ['type' => CarPropertyType::BATTERY_TOTAL_CAPACITY, 'value' => new Energy(93.4)], + ['type' => CarPropertyType::BATTERY_CELL_CHEMISTRY, 'value' => CellChemistry::LithiumNickelManganeseOxide], + ['type' => CarPropertyType::BATTERY_MODEL, 'value' => 'J1 Platform'], + ['type' => CarPropertyType::BATTERY_MANUFACTURER, 'value' => 'LG Energy Solution'], + ['type' => CarPropertyType::CHARGING_TOP_CHARGING_SPEED, 'value' => new Power(270)], + ['type' => CarPropertyType::RANGE_WLTP, 'value' => new Range(440)], + ['type' => CarPropertyType::RANGE_NEFZ, 'value' => new Range(452)], + ['type' => CarPropertyType::CATALOG_PRICE, 'value' => new Price(185456, Currency::euro())], + ['type' => CarPropertyType::IMAGE_EXTERNAL_PUBLIC_URL, 'value' => new Image(externalPublicUrl: 'https://www.porsche.com/germany/models/taycan/taycan-models/turbo-s/_jcr_content/par/twocolumnlayout/par_left/image.transform.porsche-model-desktop-xl.jpg')] + ] + ] + ] + ] + ] + ] ]; } } \ No newline at end of file diff --git a/src/Domain/AI/AIClient.php b/src/Domain/AI/AIClient.php index 5c4c05a..3dbb067 100644 --- a/src/Domain/AI/AIClient.php +++ b/src/Domain/AI/AIClient.php @@ -2,6 +2,9 @@ namespace App\Domain\AI; +use App\Domain\Model\AI\LargeEmbeddingVector; +use App\Domain\Model\AI\SmallEmbeddingVector; +use App\Domain\Model\Value\Vector; use OpenAI; class AIClient @@ -26,16 +29,23 @@ class AIClient return $response->choices[0]->message->content ?? ''; } - /** - * @return float[] - */ - public function embedText(string $text): array + public function embedTextLarge(string $text): LargeEmbeddingVector { $response = $this->client->embeddings()->create([ 'model' => 'text-embedding-3-large', 'input' => $text, ]); - return $response->embeddings[0]->embedding; + return new LargeEmbeddingVector(new Vector($response->embeddings[0]->embedding)); + } + + public function embedTextSmall(string $text): SmallEmbeddingVector + { + $response = $this->client->embeddings()->create([ + 'model' => 'text-embedding-3-small', + 'input' => $text, + ]); + + return new SmallEmbeddingVector(new Vector($response->embeddings[0]->embedding)); } } \ No newline at end of file diff --git a/src/Domain/ContentManagement/CarPropertyEmbedder.php b/src/Domain/ContentManagement/CarPropertyEmbedder.php new file mode 100644 index 0000000..3cdcfe4 --- /dev/null +++ b/src/Domain/ContentManagement/CarPropertyEmbedder.php @@ -0,0 +1,50 @@ +value instanceof Stringable)) { + return null; + } + + $text = $carProperty->type->humanReadable() . ': ' . (string) $carProperty->value; + if ($carRevision !== null) { + $text .= ' - ' . $carRevision->name; + } + if ($brand !== null) { + $text .= ', ' . $brand->name; + } + + $persistedEmbedding = $this->embeddingRepository->findByPhrase($text); + if ($persistedEmbedding) { + return $persistedEmbedding; + } + + $largeEmbedding = $this->aiClient->embedTextLarge($text); + $smallEmbedding = $this->aiClient->embedTextSmall($text); + + $embedding = new Embedding(EmbeddingId::generate(), $text, $largeEmbedding, $smallEmbedding); + $this->embeddingRepository->save($embedding); + + return $embedding; + } +} \ No newline at end of file diff --git a/src/Domain/Model/AI/Embedding.php b/src/Domain/Model/AI/Embedding.php index c2fc005..07623d4 100644 --- a/src/Domain/Model/AI/Embedding.php +++ b/src/Domain/Model/AI/Embedding.php @@ -2,9 +2,12 @@ namespace App\Domain\Model\AI; +use App\Domain\Model\Value\EmbeddingId; + class Embedding { public function __construct( + public readonly EmbeddingId $embeddingId, public readonly string $phrase, public readonly ?LargeEmbeddingVector $largeEmbeddingVector = null, public readonly ?SmallEmbeddingVector $smallEmbeddingVector = null, @@ -13,4 +16,9 @@ class Embedding throw new \InvalidArgumentException('At least one embedding vector must be provided'); } } + + public function phraseHash(): string + { + return hash('sha256', $this->phrase); + } } \ No newline at end of file diff --git a/src/Domain/Model/Battery/CellChemistry.php b/src/Domain/Model/Battery/CellChemistry.php index 4e9d707..61573f9 100644 --- a/src/Domain/Model/Battery/CellChemistry.php +++ b/src/Domain/Model/Battery/CellChemistry.php @@ -2,6 +2,8 @@ namespace App\Domain\Model\Battery; +use Stringable; + enum CellChemistry: string { case LithiumIronPhosphate = 'LFP'; diff --git a/src/Domain/Model/Brand.php b/src/Domain/Model/Brand.php index 89455b8..07f0750 100644 --- a/src/Domain/Model/Brand.php +++ b/src/Domain/Model/Brand.php @@ -2,14 +2,13 @@ namespace App\Domain\Model; +use App\Domain\Model\Value\BrandId; + final readonly class Brand { public function __construct( + public readonly BrandId $brandId, public readonly string $name, - public readonly ?string $logo = null, - public readonly ?string $description = null, - public readonly ?int $foundedYear = null, - public readonly ?string $headquarters = null, - public readonly ?string $website = null, + public readonly ?EmbeddingCollection $embeddings = null, ) {} } \ No newline at end of file diff --git a/src/Domain/Model/CarModel.php b/src/Domain/Model/CarModel.php index d37c081..38da43b 100644 --- a/src/Domain/Model/CarModel.php +++ b/src/Domain/Model/CarModel.php @@ -2,10 +2,14 @@ namespace App\Domain\Model; +use App\Domain\Model\Value\BrandId; +use App\Domain\Model\Value\CarModelId; + class CarModel { public function __construct( - public string $name, - public ?Brand $brand = null, + public readonly CarModelId $carModelId, + public readonly BrandId $brandId, + public string $name ) {} -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/Domain/Model/CarProperty.php b/src/Domain/Model/CarProperty.php new file mode 100644 index 0000000..a761ab8 --- /dev/null +++ b/src/Domain/Model/CarProperty.php @@ -0,0 +1,16 @@ +properties[] = $property; + } + + public function count(): int + { + return count($this->properties); + } + + /** + * @return CarProperty[] + */ + public function array(): array + { + return $this->properties; + } + + public function get(CarPropertyType $type): CarPropertyCollection + { + return new CarPropertyCollection(array_filter($this->properties, fn(CarProperty $property) => $property->type === $type)); + } + + public function getOne(CarPropertyType $type): ?CarProperty + { + return array_values($this->get($type)->array())[0] ?? null; + } + + /** + * @param CarPropertyType[] $types + */ + public function hasTypes(array $types): bool + { + foreach ($types as $type) { + if ($this->get($type)->count() === 0) { + return false; + } + } + + return true; + } +} \ No newline at end of file diff --git a/src/Domain/Model/CarPropertyType.php b/src/Domain/Model/CarPropertyType.php new file mode 100644 index 0000000..45913c8 --- /dev/null +++ b/src/Domain/Model/CarPropertyType.php @@ -0,0 +1,101 @@ + 'Name', + self::PRODUCTION_BEGIN => 'Production Begin', + self::PRODUCTION_END => 'Production End', + self::DRIVING_CHARACTERISTICS_POWER => 'Power', + self::DRIVING_CHARACTERISTICS_ACCELERATION => 'Acceleration', + self::DRIVING_CHARACTERISTICS_TOP_SPEED => 'Top Speed', + self::DRIVING_CHARACTERISTICS_CONSUMPTION => 'Consumption', + self::BATTERY_USABLE_CAPACITY => 'Usable Capacity', + self::BATTERY_TOTAL_CAPACITY => 'Total Capacity', + self::BATTERY_CELL_CHEMISTRY => 'Cell Chemistry', + self::BATTERY_MODEL => 'Battery Model', + self::BATTERY_MANUFACTURER => 'Battery Manufacturer', + self::CHARGING_TOP_CHARGING_SPEED => 'Top Charging Speed', + self::CHARGING_CHARGE_CURVE => 'Charge Curve', + self::CHARGING_CHARGE_TIME_PROPERTIES => 'Charge Time Properties', + self::CHARGING_CONNECTIVITY => 'Charging Connectivity', + self::CHARGING_CONNECTOR_TYPES => 'Connector Types', + self::CHARGING_PLUG_AND_CHARGE => 'Charging Plug and Charge', + self::RANGE_WLTP => 'WLTP Range', + self::RANGE_NEFZ => 'NEFZ Range', + self::RANGE_REAL_RANGE_TESTS => 'Real Range Tests', + self::CATALOG_PRICE => 'Catalog Price', + self::CATALOG_PRICE_CURRENCY => 'Catalog Price Currency', + self::CATALOG_PRICE_INCLUDES_VAT => 'Catalog Price Includes VAT', + self::IMAGE_EXTERNAL_PUBLIC_URL => 'External Public URL', + self::IMAGE_RELATIVE_PUBLIC_URL => 'Relative Public URL', + self::CHARGE_TIME_0_TO_100 => 'Charge Time 0 to 100', + self::CHARGE_TIME_0_TO_70 => 'Charge Time 0 to 70', + self::CHARGE_TIME_10_TO_70 => 'Charge Time 10 to 70', + self::CHARGE_TIME_20_TO_70 => 'Charge Time 20 to 70', + self::CHARGE_TIME_10_TO_80 => 'Charge Time 10 to 80', + self::CHARGE_TIME_20_TO_80 => 'Charge Time 20 to 80', + self::CHARGE_TIME_10_TO_90 => 'Charge Time 10 to 90', + self::CHARGE_TIME_20_TO_90 => 'Charge Time 20 to 90', + self::CHARGING_IS_400V => 'Charging is 400V', + self::CHARGING_IS_800V => 'Charging is 800V', + }; + } +} \ No newline at end of file diff --git a/src/Domain/Model/CarRevision.php b/src/Domain/Model/CarRevision.php index 53fe307..d20a422 100644 --- a/src/Domain/Model/CarRevision.php +++ b/src/Domain/Model/CarRevision.php @@ -2,31 +2,14 @@ namespace App\Domain\Model; -use App\Domain\Model\Value\Consumption; -use App\Domain\Model\Value\Date; -use App\Domain\Model\Value\Price; -use App\Domain\Model\Battery\BatteryProperties; -use App\Domain\Model\Charging\ChargingProperties; +use App\Domain\Model\Value\CarModelId; +use App\Domain\Model\Value\CarRevisionId; final readonly class CarRevision { public function __construct( - public string $name, - public ?Date $productionBegin = null, - public ?Date $productionEnd = null, - public ?DrivingCharacteristics $drivingCharacteristics = null, - public ?BatteryProperties $battery = null, - public ?ChargingProperties $chargingProperties = null, - public ?RangeProperties $rangeProperties = null, - - public ?Price $catalogPrice = null, - public ?CarModel $carModel = null, - public ?Image $image = null, - ) { - if ($this->productionBegin && $this->productionEnd) { - if ($this->productionBegin->year > $this->productionEnd->year) { - throw new \InvalidArgumentException('Production begin year must be before production end year'); - } - } - } + public readonly CarRevisionId $carRevisionId, + public readonly CarModelId $carModelId, + public readonly string $name, + ) {} } \ No newline at end of file diff --git a/src/Domain/Model/DrivingCharacteristics.php b/src/Domain/Model/DrivingCharacteristics.php deleted file mode 100644 index 7d7fef1..0000000 --- a/src/Domain/Model/DrivingCharacteristics.php +++ /dev/null @@ -1,18 +0,0 @@ -embeddings; + } +} \ No newline at end of file diff --git a/src/Domain/Model/Image.php b/src/Domain/Model/Image.php index bcc07b4..efed4b6 100644 --- a/src/Domain/Model/Image.php +++ b/src/Domain/Model/Image.php @@ -2,11 +2,18 @@ namespace App\Domain\Model; -final readonly class Image +use Stringable; + +final readonly class Image implements Stringable { public function __construct( public ?string $externalPublicUrl = null, public ?string $relativePublicUrl = null, ) { } + + public function __toString(): string + { + return $this->externalPublicUrl ?? $this->relativePublicUrl ?? ''; + } } \ No newline at end of file diff --git a/src/Domain/Model/Persistence/PersistedBrand.php b/src/Domain/Model/Persistence/PersistedBrand.php deleted file mode 100644 index bb83cff..0000000 --- a/src/Domain/Model/Persistence/PersistedBrand.php +++ /dev/null @@ -1,13 +0,0 @@ -value; + } + + public function equals(BrandId $other): bool + { + return $this->value === $other->value; + } + + public static function generate(): BrandId + { + return new BrandId(uniqid('brand_', true)); + } +} \ No newline at end of file diff --git a/src/Domain/Model/Value/CarModelId.php b/src/Domain/Model/Value/CarModelId.php new file mode 100644 index 0000000..2ab33eb --- /dev/null +++ b/src/Domain/Model/Value/CarModelId.php @@ -0,0 +1,29 @@ +value; + } + + public function equals(CarModelId $other): bool + { + return $this->value === $other->value; + } + + public static function generate(): CarModelId + { + return new CarModelId(uniqid('carmodel_', true)); + } +} \ No newline at end of file diff --git a/src/Domain/Model/Value/CarPropertyId.php b/src/Domain/Model/Value/CarPropertyId.php new file mode 100644 index 0000000..445fe01 --- /dev/null +++ b/src/Domain/Model/Value/CarPropertyId.php @@ -0,0 +1,29 @@ +value; + } + + public function equals(CarPropertyId $other): bool + { + return $this->value === $other->value; + } + + public static function generate(): CarPropertyId + { + return new CarPropertyId(uniqid('carproperty_', true)); + } +} \ No newline at end of file diff --git a/src/Domain/Model/Value/CarRevisionId.php b/src/Domain/Model/Value/CarRevisionId.php new file mode 100644 index 0000000..6cdb124 --- /dev/null +++ b/src/Domain/Model/Value/CarRevisionId.php @@ -0,0 +1,29 @@ +value; + } + + public function equals(CarRevisionId $other): bool + { + return $this->value === $other->value; + } + + public static function generate(): CarRevisionId + { + return new CarRevisionId(uniqid('carrevision_', true)); + } +} \ No newline at end of file diff --git a/src/Domain/Model/Value/Consumption.php b/src/Domain/Model/Value/Consumption.php index b6db4cc..1546cd8 100644 --- a/src/Domain/Model/Value/Consumption.php +++ b/src/Domain/Model/Value/Consumption.php @@ -2,6 +2,7 @@ namespace App\Domain\Model\Value; + class Consumption { public function __construct( diff --git a/src/Domain/Model/Value/EmbeddingId.php b/src/Domain/Model/Value/EmbeddingId.php new file mode 100644 index 0000000..027ed4a --- /dev/null +++ b/src/Domain/Model/Value/EmbeddingId.php @@ -0,0 +1,29 @@ +value; + } + + public function equals(EmbeddingId $other): bool + { + return $this->value === $other->value; + } + + public static function generate(): EmbeddingId + { + return new EmbeddingId(uniqid('embedding_', true)); + } +} \ No newline at end of file diff --git a/src/Domain/Repository/BrandRepository.php b/src/Domain/Repository/BrandRepository.php index 769735c..c566791 100644 --- a/src/Domain/Repository/BrandRepository.php +++ b/src/Domain/Repository/BrandRepository.php @@ -4,13 +4,18 @@ namespace App\Domain\Repository; use App\Domain\Model\Brand; use App\Domain\Model\BrandCollection; -use App\Domain\Model\Persistence\PersistedBrand; +use App\Domain\Model\CarModel; +use App\Domain\Model\Value\BrandId; interface BrandRepository { public function findAll(): BrandCollection; - - public function create(Brand $brand): PersistedBrand; - public function update(PersistedBrand $persistedBrand): void; + public function findByCarModel(CarModel $carModel): ?Brand; + + public function findById(BrandId $brandId): ?Brand; + + public function save(Brand $brand): void; + + public function deleteAll(): void; } \ No newline at end of file diff --git a/src/Domain/Repository/CarModelRepository.php b/src/Domain/Repository/CarModelRepository.php index a04de13..ace8158 100644 --- a/src/Domain/Repository/CarModelRepository.php +++ b/src/Domain/Repository/CarModelRepository.php @@ -4,19 +4,23 @@ namespace App\Domain\Repository; use App\Domain\Model\CarModel; use App\Domain\Model\CarModelCollection; -use App\Domain\Model\Persistence\PersistedCarModel; +use App\Domain\Model\CarRevision; +use App\Domain\Model\Value\CarModelId; +use App\Domain\Model\Value\BrandId; interface CarModelRepository { public function findAll(): CarModelCollection; - public function findById(string $id): ?PersistedCarModel; + public function findById(CarModelId $carModelId): ?CarModel; - public function findByBrandId(string $brandId): CarModelCollection; - - public function create(CarModel $carModel, string $brandId): PersistedCarModel; + public function findByBrandId(BrandId $brandId): CarModelCollection; - public function update(PersistedCarModel $persistedCarModel): void; + public function findByCarRevision(CarRevision $carRevision): ?CarModel; - public function delete(PersistedCarModel $persistedCarModel): void; -} \ No newline at end of file + public function save(CarModel $carModel): void; + + public function delete(CarModel $carModel): void; + + public function deleteAll(): void; +} \ No newline at end of file diff --git a/src/Domain/Repository/CarPropertyRepository.php b/src/Domain/Repository/CarPropertyRepository.php new file mode 100644 index 0000000..a561119 --- /dev/null +++ b/src/Domain/Repository/CarPropertyRepository.php @@ -0,0 +1,30 @@ +embeddingRepository->searchByLargeEmbeddingVector($this->aiClient->embedTextLarge($query)); - $chargingSpeed = new ChargingSpeed( - dcMaxKw: new Power(175), - acMaxKw: new Power(11) - ); + $carProperties = $this->carPropertyRepository->findByEmbeddingPhraseHashes(array_map(fn (Embedding $embedding) => $embedding->phraseHash(), $results->array())); - return new TileCollection([ - new SectionTile('Skoda Enyaq iV 85', [ - new CarTile($skodaElroq85->image ?? new Image(), array_filter([ - new BrandTile('Skoda'), - $skodaElroq85->catalogPrice ? new PriceTile($skodaElroq85->catalogPrice) : null, - new AvailabilityTile('Verfügbar', new Date(1, 1, 2020)), - new RangeTile($wltpRange->range), - $drivingCharacteristics->consumption ? new ConsumptionTile($drivingCharacteristics->consumption) : null, - $drivingCharacteristics->acceleration ? new AccelerationTile($drivingCharacteristics->acceleration) : null, - ])), - - new SubSectionTile('Performance', array_filter([ - $drivingCharacteristics->power ? new PowerTile($drivingCharacteristics->power) : null, - $drivingCharacteristics->topSpeed ? new TopSpeedTile($drivingCharacteristics->topSpeed) : null, - new DrivetrainTile(new Drivetrain('rear')), - ])), - - new SubSectionTile('Reichweite', [ - new RangeTile($wltpRange->range), - new RealRangeTile($realRangeTests), - ]), - - new SubSectionTile('Batterie', array_filter([ - $skodaElroq85->battery ? new BatteryTile($skodaElroq85->battery) : null, - $skodaElroq85->battery ? new BatteryDetailsTile($skodaElroq85->battery) : null, - ])), - - new SubSectionTile('Laden', array_filter([ - new ChargingTile($chargingSpeed), - $chargingProperties->chargeTimeProperties ? new ChargeTimeTile($chargingProperties->chargeTimeProperties) : null, - $chargingProperties->chargingConnectivity ? new ChargingConnectivityTile($chargingProperties->chargingConnectivity) : null, - ])), - ]), - ]); + return $this->tileBuilder->build($carProperties); } } \ No newline at end of file diff --git a/src/Domain/Search/TileBuilder.php b/src/Domain/Search/TileBuilder.php new file mode 100644 index 0000000..36db1ef --- /dev/null +++ b/src/Domain/Search/TileBuilder.php @@ -0,0 +1,58 @@ +carTileBuilder = new CarTileBuilder(); + } + + public function build(CarPropertyCollection $carProperties): TileCollection + { + $carRevisionGroups = []; + foreach ($carProperties->array() as $carProperty) { + $carRevisionId = $carProperty->carRevisionId->value; + if (!isset($carRevisionGroups[$carRevisionId])) { + $carRevisionGroups[$carRevisionId] = new CarPropertyCollection(); + } + $carRevisionGroups[$carRevisionId]->add($carProperty); + } + + $tiles = new TileCollection([]); + + foreach ($carRevisionGroups as $carRevisionId => $properties) { + $persistedCarRevision = $this->carRevisionRepository->findById(new CarRevisionId($carRevisionId)); + if ($persistedCarRevision === null) { + continue; + } + + $carModel = $this->carModelRepository->findByCarRevision($persistedCarRevision); + if ($carModel === null) { + continue; + } + + $brand = $this->brandRepository->findByCarModel($carModel); + if ($brand === null) { + continue; + } + + $this->carTileBuilder->build($brand, $carModel, $persistedCarRevision, $properties, $tiles); + } + + return $tiles; + } +} \ No newline at end of file diff --git a/src/Domain/Search/TileBuilders/CarTileBuilder.php b/src/Domain/Search/TileBuilders/CarTileBuilder.php new file mode 100644 index 0000000..0129d71 --- /dev/null +++ b/src/Domain/Search/TileBuilders/CarTileBuilder.php @@ -0,0 +1,67 @@ +hasTypes([ + CarPropertyType::CATALOG_PRICE, + CarPropertyType::IMAGE_EXTERNAL_PUBLIC_URL, + CarPropertyType::DRIVING_CHARACTERISTICS_ACCELERATION, + ])) { + $imageProperty = $carProperties->getOne(CarPropertyType::IMAGE_EXTERNAL_PUBLIC_URL); + $priceProperty = $carProperties->getOne(CarPropertyType::CATALOG_PRICE); + $accelerationProperty = $carProperties->getOne(CarPropertyType::DRIVING_CHARACTERISTICS_ACCELERATION); + + if ($imageProperty !== null && $priceProperty !== null && $accelerationProperty !== null) { + // Handle Image - it expects externalPublicUrl as string|null + $imageValue = $imageProperty->value; + $image = $imageValue instanceof Image ? $imageValue : new Image(is_string($imageValue) ? $imageValue : null); + + // Handle Price - it should already be a Price object + $priceValue = $priceProperty->value; + if (!$priceValue instanceof Price) { + return; // Skip if not a Price object + } + + // Handle Acceleration - it should already be an Acceleration object + $accelerationValue = $accelerationProperty->value; + if (!$accelerationValue instanceof Acceleration) { + return; // Skip if not an Acceleration object + } + + $subTiles->add(new CarTile( + $image, + [ + new PriceTile($priceValue), + new AccelerationTile($accelerationValue), + ] + )); + } + } + + $tiles->add(new SectionTile( + $carRevision->name, + $subTiles->array() + )); + } +} \ No newline at end of file diff --git a/src/Domain/Search/TileCollection.php b/src/Domain/Search/TileCollection.php index 1c551c9..7bd17b9 100644 --- a/src/Domain/Search/TileCollection.php +++ b/src/Domain/Search/TileCollection.php @@ -2,22 +2,25 @@ namespace App\Domain\Search; -use App\Domain\Search\Tiles\SectionTile; - -class TileCollection +final class TileCollection { /** - * @param SectionTile[] $tiles + * @param object[] $tiles */ public function __construct( - private readonly array $tiles, + private array $tiles, ) {} /** - * @return SectionTile[] + * @return object[] */ public function array(): array { return $this->tiles; } + + public function add(object $tile): void + { + $this->tiles[] = $tile; + } } \ No newline at end of file diff --git a/src/Domain/Search/Tiles/PerformanceOverviewTile.php b/src/Domain/Search/Tiles/PerformanceOverviewTile.php deleted file mode 100644 index 092fd75..0000000 --- a/src/Domain/Search/Tiles/PerformanceOverviewTile.php +++ /dev/null @@ -1,12 +0,0 @@ - $data + * @param array $row - Single brand row from database */ - public function map(array $data): Brand + public function map(array $row): Brand { + $brandId = is_string($row['id'] ?? null) ? $row['id'] : throw new \InvalidArgumentException('Brand ID is required'); + return new Brand( - 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, + brandId: new BrandId($brandId), + name: is_string($row['name'] ?? null) ? $row['name'] : throw new \InvalidArgumentException('Brand name is required'), ); } -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/Infrastructure/PostgreSQL/Repository/BrandRepository/PostgreSQLBrandRepository.php b/src/Infrastructure/PostgreSQL/Repository/BrandRepository/PostgreSQLBrandRepository.php index 2da2b6b..8a0c4f6 100644 --- a/src/Infrastructure/PostgreSQL/Repository/BrandRepository/PostgreSQLBrandRepository.php +++ b/src/Infrastructure/PostgreSQL/Repository/BrandRepository/PostgreSQLBrandRepository.php @@ -4,7 +4,8 @@ namespace App\Infrastructure\PostgreSQL\Repository\BrandRepository; use App\Domain\Model\Brand; use App\Domain\Model\BrandCollection; -use App\Domain\Model\Persistence\PersistedBrand; +use App\Domain\Model\CarModel; +use App\Domain\Model\Value\BrandId; use App\Domain\Repository\BrandRepository; use Doctrine\DBAL\Connection; @@ -12,77 +13,67 @@ final class PostgreSQLBrandRepository implements BrandRepository { public function __construct( private readonly Connection $connection, - ) { - } + ) {} public function findAll(): BrandCollection { $sql = 'SELECT * FROM brands ORDER BY name ASC'; - $result = $this->connection->executeQuery($sql); + $result = $this->connection->executeQuery($sql); $brands = []; $mapper = new ModelMapper(); - foreach ($result->fetchAllAssociative() as $brand) { - $brands[] = $mapper->map($brand); + foreach ($result->fetchAllAssociative() as $row) { + $brands[] = $mapper->map($row); } return new BrandCollection($brands); } - public function create(Brand $brand): PersistedBrand + public function findByCarModel(CarModel $carModel): ?Brand { - // Generate an ID for the brand since Brand model doesn't have one - $brandId = uniqid('brand_', true); + $sql = 'SELECT brands.* FROM brands + INNER JOIN car_models ON brands.id = car_models.brand_id + WHERE car_models.id = ?'; - $sql = <<<'SQL' - INSERT INTO brands (id, name, content) - VALUES (?, ?, ?) - ON CONFLICT (id) DO UPDATE SET - name = EXCLUDED.name, - content = EXCLUDED.content - SQL; + $result = $this->connection->executeQuery($sql, [$carModel->carModelId->value]); + $row = $result->fetchAssociative(); - $content = json_encode([ - 'logo' => $brand->logo, - 'description' => $brand->description, - 'founded_year' => $brand->foundedYear, - 'headquarters' => $brand->headquarters, - 'website' => $brand->website, - ]); + if ($row === false) { + return null; + } - $this->connection->executeStatement($sql, [ - $brandId, - $brand->name, - $content, - ]); - - return new PersistedBrand($brandId, $brand); + $mapper = new ModelMapper(); + return $mapper->map($row); } - public function update(PersistedBrand $persistedBrand): void + public function findById(BrandId $brandId): ?Brand { - $brand = $persistedBrand->brand; + $sql = 'SELECT * FROM brands WHERE id = ?'; - $sql = <<<'SQL' - UPDATE brands SET - name = ?, - content = ? - WHERE id = ? - SQL; + $result = $this->connection->executeQuery($sql, [$brandId->value]); + $row = $result->fetchAssociative(); - $content = json_encode([ - 'logo' => $brand->logo, - 'description' => $brand->description, - 'founded_year' => $brand->foundedYear, - 'headquarters' => $brand->headquarters, - 'website' => $brand->website, - ]); + if ($row === false) { + return null; + } + + $mapper = new ModelMapper(); + return $mapper->map($row); + } + + public function save(Brand $brand): void + { + $sql = 'INSERT INTO brands (id, name) VALUES (?, ?) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name'; $this->connection->executeStatement($sql, [ - $brand->name, - $content, - $persistedBrand->id, + $brand->brandId->value, + $brand->name ]); } + + public function deleteAll(): void + { + $this->connection->executeStatement('DELETE FROM brands'); + } } \ No newline at end of file diff --git a/src/Infrastructure/PostgreSQL/Repository/CarModelRepository/ModelMapper.php b/src/Infrastructure/PostgreSQL/Repository/CarModelRepository/ModelMapper.php index 130a233..7efa6bf 100644 --- a/src/Infrastructure/PostgreSQL/Repository/CarModelRepository/ModelMapper.php +++ b/src/Infrastructure/PostgreSQL/Repository/CarModelRepository/ModelMapper.php @@ -4,25 +4,23 @@ namespace App\Infrastructure\PostgreSQL\Repository\CarModelRepository; use App\Domain\Model\CarModel; use App\Domain\Model\Brand; +use App\Domain\Model\Value\BrandId; +use App\Domain\Model\Value\CarModelId; class ModelMapper { /** - * @param array $data + * @param array $row - Single car model row from database */ - public function map(array $data): CarModel + public function map(array $row): CarModel { - $contentString = is_string($data['content'] ?? null) ? $data['content'] : '{}'; - $content = json_decode($contentString, true); - - $brand = null; - if (is_array($content) && !empty($content['brand']) && is_string($content['brand'])) { - $brand = new Brand(name: $content['brand']); - } - + $carModelId = is_string($row['id'] ?? null) ? $row['id'] : throw new \InvalidArgumentException('CarModel ID is required'); + $brandId = is_string($row['brand_id'] ?? null) ? $row['brand_id'] : throw new \InvalidArgumentException('Brand ID is required'); + return new CarModel( - name: is_string($data['name'] ?? null) ? $data['name'] : '', - brand: $brand, + carModelId: new CarModelId($carModelId), + brandId: new BrandId($brandId), + name: is_string($row['name'] ?? null) ? $row['name'] : throw new \InvalidArgumentException('CarModel name is required'), ); } -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/Infrastructure/PostgreSQL/Repository/CarModelRepository/PostgreSQLCarModelRepository.php b/src/Infrastructure/PostgreSQL/Repository/CarModelRepository/PostgreSQLCarModelRepository.php index fc7426f..0f6dae0 100644 --- a/src/Infrastructure/PostgreSQL/Repository/CarModelRepository/PostgreSQLCarModelRepository.php +++ b/src/Infrastructure/PostgreSQL/Repository/CarModelRepository/PostgreSQLCarModelRepository.php @@ -4,7 +4,9 @@ namespace App\Infrastructure\PostgreSQL\Repository\CarModelRepository; use App\Domain\Model\CarModel; use App\Domain\Model\CarModelCollection; -use App\Domain\Model\Persistence\PersistedCarModel; +use App\Domain\Model\CarRevision; +use App\Domain\Model\Value\CarModelId; +use App\Domain\Model\Value\BrandId; use App\Domain\Repository\CarModelRepository; use Doctrine\DBAL\Connection; @@ -12,108 +14,89 @@ final class PostgreSQLCarModelRepository implements CarModelRepository { public function __construct( private readonly Connection $connection, - ) { - } + ) {} public function findAll(): CarModelCollection { $sql = 'SELECT * FROM car_models ORDER BY name ASC'; - $result = $this->connection->executeQuery($sql); + $result = $this->connection->executeQuery($sql); $carModels = []; $mapper = new ModelMapper(); - foreach ($result->fetchAllAssociative() as $carModel) { - $carModels[] = $mapper->map($carModel); + foreach ($result->fetchAllAssociative() as $row) { + $carModels[] = $mapper->map($row); } return new CarModelCollection($carModels); } - public function findById(string $id): ?PersistedCarModel + public function findById(CarModelId $carModelId): ?CarModel { $sql = 'SELECT * FROM car_models WHERE id = ?'; - $result = $this->connection->executeQuery($sql, [$id]); - $data = $result->fetchAssociative(); - if (!$data) { + $result = $this->connection->executeQuery($sql, [$carModelId->value]); + $row = $result->fetchAssociative(); + + if ($row === false) { return null; } $mapper = new ModelMapper(); - $carModel = $mapper->map($data); - - return new PersistedCarModel(is_string($data['id'] ?? null) ? $data['id'] : '', $carModel); + return $mapper->map($row); } - public function findByBrandId(string $brandId): CarModelCollection + public function findByBrandId(BrandId $brandId): CarModelCollection { $sql = 'SELECT * FROM car_models WHERE brand_id = ? ORDER BY name ASC'; - $result = $this->connection->executeQuery($sql, [$brandId]); + $result = $this->connection->executeQuery($sql, [$brandId->value]); $carModels = []; $mapper = new ModelMapper(); - foreach ($result->fetchAllAssociative() as $carModel) { - $carModels[] = $mapper->map($carModel); + foreach ($result->fetchAllAssociative() as $row) { + $carModels[] = $mapper->map($row); } return new CarModelCollection($carModels); } - public function create(CarModel $carModel, string $brandId): PersistedCarModel + public function findByCarRevision(CarRevision $carRevision): ?CarModel { - // Generate an ID for the car model - $carModelId = uniqid('carmodel_', true); + $sql = 'SELECT car_models.* FROM car_models + INNER JOIN car_revisions ON car_models.id = car_revisions.car_model_id + WHERE car_revisions.id = ?'; - $sql = <<<'SQL' - INSERT INTO car_models (id, brand_id, name, content) - VALUES (?, ?, ?, ?) - ON CONFLICT (id) DO UPDATE SET - brand_id = EXCLUDED.brand_id, - name = EXCLUDED.name, - content = EXCLUDED.content - SQL; + $result = $this->connection->executeQuery($sql, [$carRevision->carRevisionId->value]); + $row = $result->fetchAssociative(); - $content = json_encode([ - 'brand' => $carModel->brand->name ?? null, - ]); + if ($row === false) { + return null; + } - $this->connection->executeStatement($sql, [ - $carModelId, - $brandId, - $carModel->name, - $content, - ]); - - return new PersistedCarModel($carModelId, $carModel); + $mapper = new ModelMapper(); + return $mapper->map($row); } - public function update(PersistedCarModel $persistedCarModel): void + public function save(CarModel $carModel): void { - $carModel = $persistedCarModel->carModel; - - $sql = <<<'SQL' - UPDATE car_models SET - name = ?, - content = ? - WHERE id = ? - SQL; - - $content = json_encode([ - 'brand' => $carModel->brand->name ?? null, - ]); + $sql = 'INSERT INTO car_models (id, brand_id, name) VALUES (?, ?, ?) ON CONFLICT (id) DO UPDATE SET brand_id = EXCLUDED.brand_id, name = EXCLUDED.name'; $this->connection->executeStatement($sql, [ + $carModel->carModelId->value, + $carModel->brandId->value, $carModel->name, - $content, - $persistedCarModel->id, ]); } - public function delete(PersistedCarModel $persistedCarModel): void + public function delete(CarModel $carModel): void { $sql = 'DELETE FROM car_models WHERE id = ?'; - $this->connection->executeStatement($sql, [$persistedCarModel->id]); + $this->connection->executeStatement($sql, [$carModel->carModelId->value]); } -} \ No newline at end of file + + public function deleteAll(): void + { + $this->connection->executeStatement('DELETE FROM car_models'); + } +} \ No newline at end of file diff --git a/src/Infrastructure/PostgreSQL/Repository/CarPropertyRepository/PostgreSQLCarPropertyRepository.php b/src/Infrastructure/PostgreSQL/Repository/CarPropertyRepository/PostgreSQLCarPropertyRepository.php new file mode 100644 index 0000000..f3d1a75 --- /dev/null +++ b/src/Infrastructure/PostgreSQL/Repository/CarPropertyRepository/PostgreSQLCarPropertyRepository.php @@ -0,0 +1,121 @@ +connection->executeQuery( + 'SELECT * FROM car_properties WHERE car_revision_id = ?', + [$carRevision->carRevisionId->value] + ); + + $carProperties = []; + foreach ($result->fetchAllAssociative() as $row) { + $carProperty = $this->mapRowToCarProperty($row); + if ($carProperty !== null) { + $carProperties[] = $carProperty; + } + } + + return new CarPropertyCollection($carProperties); + } + + public function findByEmbeddingIds(array $embeddingIds): CarPropertyCollection + { + // Placeholder implementation - would need to join with embeddings table + return new CarPropertyCollection([]); + } + + public function findByEmbeddingPhraseHashes(array $phraseHashes): CarPropertyCollection + { + if (empty($phraseHashes)) { + return new CarPropertyCollection([]); + } + + $placeholders = str_repeat('?,', count($phraseHashes) - 1) . '?'; + $result = $this->connection->executeQuery( + "SELECT cp.* FROM car_properties cp + INNER JOIN embeddings e ON e.phrase_hash IN ($placeholders) + WHERE cp.id = e.car_property_id", + array_values($phraseHashes) + ); + + $carProperties = []; + foreach ($result->fetchAllAssociative() as $row) { + $carProperty = $this->mapRowToCarProperty($row); + if ($carProperty !== null) { + $carProperties[] = $carProperty; + } + } + + return new CarPropertyCollection($carProperties); + } + + public function save(CarProperty $carProperty): void + { + $this->connection->executeStatement( + 'INSERT INTO car_properties (id, car_revision_id, type, value) VALUES (?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET car_revision_id = EXCLUDED.car_revision_id, type = EXCLUDED.type, value = EXCLUDED.value', + [ + $carProperty->carPropertyId->value, + $carProperty->carRevisionId->value, + $carProperty->type->value, + serialize($carProperty->value), + ] + ); + } + + public function delete(CarProperty $carProperty): void + { + $this->connection->executeStatement( + 'DELETE FROM car_properties WHERE id = ?', + [$carProperty->carPropertyId->value] + ); + } + + public function deleteAll(): void + { + $this->connection->executeStatement('DELETE FROM car_properties'); + } + + /** + * @param array $row + */ + private function mapRowToCarProperty(array $row): ?CarProperty + { + try { + $id = $row['id'] ?? null; + $carRevisionId = $row['car_revision_id'] ?? null; + $type = $row['type'] ?? null; + $value = $row['value'] ?? null; + + if (!is_string($id) || !is_string($carRevisionId) || !is_string($type) || !is_string($value)) { + return null; + } + + return new CarProperty( + new CarPropertyId($id), + new CarRevisionId($carRevisionId), + CarPropertyType::from($type), + unserialize($value) + ); + } catch (\Exception $e) { + // Invalid data, skip this row + return null; + } + } +} \ No newline at end of file diff --git a/src/Infrastructure/PostgreSQL/Repository/CarRevisionRepository/ModelMapper.php b/src/Infrastructure/PostgreSQL/Repository/CarRevisionRepository/ModelMapper.php index be92a90..51f3d1e 100644 --- a/src/Infrastructure/PostgreSQL/Repository/CarRevisionRepository/ModelMapper.php +++ b/src/Infrastructure/PostgreSQL/Repository/CarRevisionRepository/ModelMapper.php @@ -3,128 +3,23 @@ namespace App\Infrastructure\PostgreSQL\Repository\CarRevisionRepository; use App\Domain\Model\CarRevision; -use App\Domain\Model\DrivingCharacteristics; -use App\Domain\Model\Image; -use App\Domain\Model\Value\Date; -use App\Domain\Model\Value\Price; -use App\Domain\Model\Value\Currency; -use App\Domain\Model\Battery\BatteryProperties; -use App\Domain\Model\Battery\CellChemistry; -use App\Domain\Model\Charging\ChargingProperties; -use App\Domain\Model\RangeProperties; -use App\Domain\Model\Range\WltpRange; -use App\Domain\Model\Range\NefzRange; -use App\Domain\Model\Value\Energy; -use App\Domain\Model\Value\Power; -use App\Domain\Model\Value\Acceleration; -use App\Domain\Model\Value\Speed; -use App\Domain\Model\Value\Consumption; -use App\Domain\Model\Value\Range; +use App\Domain\Model\Value\CarModelId; +use App\Domain\Model\Value\CarRevisionId; class ModelMapper { /** - * @param array $data + * @param array $row - Single car revision row from database */ - public function map(array $data): CarRevision + public function map(array $row): CarRevision { - $contentString = is_string($data['content'] ?? null) ? $data['content'] : '{}'; - $content = json_decode($contentString, true); - - if (!is_array($content)) { - $content = []; - } - - $productionBegin = null; - if (isset($content['production_begin']) && is_numeric($content['production_begin'])) { - $productionBegin = new Date(1, 1, (int) $content['production_begin']); - } - - $productionEnd = null; - if (isset($content['production_end']) && is_numeric($content['production_end'])) { - $productionEnd = new Date(1, 1, (int) $content['production_end']); - } - - $catalogPrice = null; - 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(), - default => Currency::euro(), // fallback to euro - }; - - $catalogPrice = new Price( - (int) $content['catalog_price'], - $currency - ); - } - - $image = null; - if (isset($content['image_url']) && is_string($content['image_url'])) { - $image = new Image($content['image_url']); - } - - $drivingCharacteristics = null; - if (isset($content['driving_characteristics']) && is_array($content['driving_characteristics'])) { - $dc = $content['driving_characteristics']; - $drivingCharacteristics = new DrivingCharacteristics( - 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 (isset($content['battery']) && is_array($content['battery'])) { - $b = $content['battery']; - 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((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 (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((float) $content['charging']['top_charging_speed_kw']) - ); - } - - $rangeProperties = null; - if (isset($content['range']) && is_array($content['range'])) { - $r = $content['range']; - $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( - wltp: $wltp, - nefz: $nefz, - ); - } - } - + $carRevisionId = is_string($row['id'] ?? null) ? $row['id'] : throw new \InvalidArgumentException('CarRevision ID is required'); + $carModelId = is_string($row['car_model_id'] ?? null) ? $row['car_model_id'] : throw new \InvalidArgumentException('CarModel ID is required'); + return new CarRevision( - name: is_string($data['name'] ?? null) ? $data['name'] : '', - productionBegin: $productionBegin, - productionEnd: $productionEnd, - drivingCharacteristics: $drivingCharacteristics, - battery: $battery, - chargingProperties: $chargingProperties, - rangeProperties: $rangeProperties, - catalogPrice: $catalogPrice, - carModel: null, // CarModel would need to be loaded separately if needed - image: $image, + carRevisionId: new CarRevisionId($carRevisionId), + carModelId: new CarModelId($carModelId), + name: is_string($row['name'] ?? null) ? $row['name'] : throw new \InvalidArgumentException('CarRevision name is required'), ); } -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/Infrastructure/PostgreSQL/Repository/CarRevisionRepository/PostgreSQLCarRevisionRepository.php b/src/Infrastructure/PostgreSQL/Repository/CarRevisionRepository/PostgreSQLCarRevisionRepository.php index 0096bb5..d58f214 100644 --- a/src/Infrastructure/PostgreSQL/Repository/CarRevisionRepository/PostgreSQLCarRevisionRepository.php +++ b/src/Infrastructure/PostgreSQL/Repository/CarRevisionRepository/PostgreSQLCarRevisionRepository.php @@ -4,7 +4,8 @@ namespace App\Infrastructure\PostgreSQL\Repository\CarRevisionRepository; use App\Domain\Model\CarRevision; use App\Domain\Model\CarRevisionCollection; -use App\Domain\Model\Persistence\PersistedCarRevision; +use App\Domain\Model\Value\CarRevisionId; +use App\Domain\Model\Value\CarModelId; use App\Domain\Repository\CarRevisionRepository; use Doctrine\DBAL\Connection; @@ -12,156 +13,74 @@ final class PostgreSQLCarRevisionRepository implements CarRevisionRepository { public function __construct( private readonly Connection $connection, - ) { - } + ) {} public function findAll(): CarRevisionCollection { $sql = 'SELECT * FROM car_revisions ORDER BY name ASC'; - $result = $this->connection->executeQuery($sql); + $result = $this->connection->executeQuery($sql); $carRevisions = []; $mapper = new ModelMapper(); - foreach ($result->fetchAllAssociative() as $carRevision) { - $carRevisions[] = $mapper->map($carRevision); + foreach ($result->fetchAllAssociative() as $row) { + $carRevisions[] = $mapper->map($row); } return new CarRevisionCollection($carRevisions); } - public function findById(string $id): ?PersistedCarRevision + public function findById(CarRevisionId $carRevisionId): ?CarRevision { $sql = 'SELECT * FROM car_revisions WHERE id = ?'; - $result = $this->connection->executeQuery($sql, [$id]); - $data = $result->fetchAssociative(); - if (!$data) { + $result = $this->connection->executeQuery($sql, [$carRevisionId->value]); + $row = $result->fetchAssociative(); + + if ($row === false) { return null; } $mapper = new ModelMapper(); - $carRevision = $mapper->map($data); - - return new PersistedCarRevision(is_string($data['id'] ?? null) ? $data['id'] : '', $carRevision); + return $mapper->map($row); } - public function findByCarModelId(string $carModelId): CarRevisionCollection + public function findByCarModelId(CarModelId $carModelId): CarRevisionCollection { $sql = 'SELECT * FROM car_revisions WHERE car_model_id = ? ORDER BY name ASC'; - $result = $this->connection->executeQuery($sql, [$carModelId]); + $result = $this->connection->executeQuery($sql, [$carModelId->value]); $carRevisions = []; $mapper = new ModelMapper(); - foreach ($result->fetchAllAssociative() as $carRevision) { - $carRevisions[] = $mapper->map($carRevision); + foreach ($result->fetchAllAssociative() as $row) { + $carRevisions[] = $mapper->map($row); } return new CarRevisionCollection($carRevisions); } - public function create(CarRevision $carRevision, string $carModelId): PersistedCarRevision + public function save(CarRevision $carRevision): void { - // Generate an ID for the car revision - $carRevisionId = uniqid('carrevision_', true); - - $sql = <<<'SQL' - INSERT INTO car_revisions (id, car_model_id, name, content) - VALUES (?, ?, ?, ?) - ON CONFLICT (id) DO UPDATE SET - car_model_id = EXCLUDED.car_model_id, - name = EXCLUDED.name, - content = EXCLUDED.content - SQL; - - $content = json_encode([ - '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->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, - '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, - ], - 'charging' => [ - 'top_charging_speed_kw' => $carRevision->chargingProperties->topChargingSpeed->kilowatts ?? null, - ], - 'range' => [ - 'wltp_km' => $carRevision->rangeProperties?->wltp?->range->kilometers ?? null, - 'nefz_km' => $carRevision->rangeProperties?->nefz?->range->kilometers ?? null, - ], - ]); + $sql = 'INSERT INTO car_revisions (id, car_model_id, name) VALUES (?, ?, ?) ON CONFLICT (id) DO UPDATE SET car_model_id = EXCLUDED.car_model_id, name = EXCLUDED.name'; $this->connection->executeStatement($sql, [ - $carRevisionId, - $carModelId, - $carRevision->name, - $content, + $carRevision->carRevisionId->value, + $carRevision->carModelId->value, + $carRevision->name ]); - return new PersistedCarRevision($carRevisionId, $carRevision); + return; } - public function update(PersistedCarRevision $persistedCarRevision): void - { - $carRevision = $persistedCarRevision->carRevision; - - $sql = <<<'SQL' - UPDATE car_revisions SET - name = ?, - content = ? - WHERE id = ? - SQL; - - $content = json_encode([ - '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->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, - '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, - ], - 'charging' => [ - 'top_charging_speed_kw' => $carRevision->chargingProperties->topChargingSpeed->kilowatts ?? null, - ], - 'range' => [ - 'wltp_km' => $carRevision->rangeProperties?->wltp?->range->kilometers ?? null, - 'nefz_km' => $carRevision->rangeProperties?->nefz?->range->kilometers ?? null, - ], - ]); - - $this->connection->executeStatement($sql, [ - $carRevision->name, - $content, - $persistedCarRevision->id, - ]); - } - - public function delete(PersistedCarRevision $persistedCarRevision): void + public function delete(CarRevision $carRevision): void { $sql = 'DELETE FROM car_revisions WHERE id = ?'; - $this->connection->executeStatement($sql, [$persistedCarRevision->id]); + $this->connection->executeStatement($sql, [$carRevision->carRevisionId->value]); } -} \ No newline at end of file + + public function deleteAll(): void + { + $this->connection->executeStatement('DELETE FROM car_revisions'); + } +} \ No newline at end of file diff --git a/src/Infrastructure/PostgreSQL/Repository/EmbeddingRepository/PostgreSQLEmbeddingRepository.php b/src/Infrastructure/PostgreSQL/Repository/EmbeddingRepository/PostgreSQLEmbeddingRepository.php index 23630dd..3a56023 100644 --- a/src/Infrastructure/PostgreSQL/Repository/EmbeddingRepository/PostgreSQLEmbeddingRepository.php +++ b/src/Infrastructure/PostgreSQL/Repository/EmbeddingRepository/PostgreSQLEmbeddingRepository.php @@ -7,84 +7,148 @@ 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; +use App\Domain\Model\EmbeddingCollection; +use App\Domain\Model\Value\Vector; +use App\Domain\Model\Value\EmbeddingId; -class PostgreSQLEmbeddingRepository implements EmbeddingRepository +final class PostgreSQLEmbeddingRepository implements EmbeddingRepository { public function __construct( private readonly Connection $connection, - ) { - } + ) {} - public function create(Embedding $embedding): PersistedEmbedding + public function save(Embedding $embedding): void { - $hash = md5($embedding->phrase); - $this->connection->executeStatement(<< $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, + $this->connection->executeStatement( + 'INSERT INTO embeddings (id, phrase_hash, phrase, large_embedding_vector, small_embedding_vector) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING', + [ + $embedding->embeddingId->value, + $embedding->phraseHash(), + $embedding->phrase, + $embedding->largeEmbeddingVector !== null + ? '[' . implode(',', $embedding->largeEmbeddingVector->vector->values) . ']' + : null, + $embedding->smallEmbeddingVector !== null + ? '[' . implode(',', $embedding->smallEmbeddingVector->vector->values) . ']' + : null, + ] ); } - public function delete(PersistedEmbedding $persistedEmbedding): void + public function delete(Embedding $embedding): void { - $this->connection->delete('embeddings', ['phrase_hash' => $persistedEmbedding->phraseHash]); + $this->connection->executeStatement( + 'DELETE FROM embeddings WHERE id = ?', + [$embedding->embeddingId->value] + ); } - public function searchByLargeEmbeddingVector(LargeEmbeddingVector $embeddingVector, int $limit = 10): array + public function findByPhrase(string $phrase): ?Embedding { - $result = $this->connection->executeQuery(<< :vector - LIMIT :limit - SQL, [ - 'vector' => '[' . implode(',', $embeddingVector->vector->values) . ']', - 'limit' => $limit, - ]); + $result = $this->connection->executeQuery( + 'SELECT * FROM embeddings WHERE phrase = ?', + [$phrase] + ); + + $row = $result->fetchAssociative(); + if ($row === false) { + return null; + } + + return $this->mapRowToEmbedding($row); + } + + public function searchByLargeEmbeddingVector(LargeEmbeddingVector $embeddingVector, int $limit = 10): EmbeddingCollection + { + $result = $this->connection->executeQuery( + 'SELECT * + FROM embeddings + WHERE large_embedding_vector IS NOT NULL + ORDER BY large_embedding_vector <=> ? + LIMIT ?', + [ + '[' . implode(',', $embeddingVector->vector->values) . ']', + $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) - ); + $embeddings[] = $this->mapRowToEmbedding($row); } - return $embeddings; + return new EmbeddingCollection($embeddings); } - public function searchBySmallEmbeddingVector(SmallEmbeddingVector $smallEmbeddingVector, int $limit = 10): array + public function searchBySmallEmbeddingVector(SmallEmbeddingVector $smallEmbeddingVector, int $limit = 10): EmbeddingCollection { - $result = $this->connection->executeQuery(<< :vector - LIMIT :limit - SQL, [ - 'vector' => '[' . implode(',', $smallEmbeddingVector->vector->values) . ']', - 'limit' => $limit, - ]); + $result = $this->connection->executeQuery( + 'SELECT * + FROM embeddings + WHERE small_embedding_vector IS NOT NULL + ORDER BY small_embedding_vector <=> ? + LIMIT ?', + [ + '[' . implode(',', $smallEmbeddingVector->vector->values) . ']', + $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) - ); + $embeddings[] = $this->mapRowToEmbedding($row); } - return $embeddings; + return new EmbeddingCollection($embeddings); + } + + public function deleteAll(): void + { + $this->connection->executeStatement('DELETE FROM embeddings'); + } + + /** + * @param array $row + */ + private function mapRowToEmbedding(array $row): Embedding + { + $largeEmbeddingVector = null; + if (is_string($row['large_embedding_vector'] ?? null)) { + $largeValues = json_decode($row['large_embedding_vector'], true); + if (is_array($largeValues)) { + $floatValues = []; + foreach ($largeValues as $value) { + if (is_numeric($value)) { + $floatValues[] = (float) $value; + } + } + if (count($floatValues) === count($largeValues)) { + $largeEmbeddingVector = new LargeEmbeddingVector(new Vector($floatValues)); + } + } + } + + $smallEmbeddingVector = null; + if (is_string($row['small_embedding_vector'] ?? null)) { + $smallValues = json_decode($row['small_embedding_vector'], true); + if (is_array($smallValues)) { + $floatValues = []; + foreach ($smallValues as $value) { + if (is_numeric($value)) { + $floatValues[] = (float) $value; + } + } + if (count($floatValues) === count($smallValues)) { + $smallEmbeddingVector = new SmallEmbeddingVector(new Vector($floatValues)); + } + } + } + + return new Embedding( + embeddingId: new EmbeddingId(is_string($row['id'] ?? null) ? $row['id'] : throw new \InvalidArgumentException('Embedding ID is required')), + phrase: is_string($row['phrase'] ?? null) ? $row['phrase'] : throw new \InvalidArgumentException('Phrase is required'), + largeEmbeddingVector: $largeEmbeddingVector, + smallEmbeddingVector: $smallEmbeddingVector, + ); } } \ No newline at end of file diff --git a/templates/home/index.html.twig b/templates/home/index.html.twig index f3de153..86a3136 100644 --- a/templates/home/index.html.twig +++ b/templates/home/index.html.twig @@ -13,7 +13,7 @@

Popular Electric Vehicle Brands

{% for brand in brands %} -
+
{{ brand.name }}
{% if brand.description %}
{{ brand.description }}