From acd669e180addffaed6ad775dce3cbd85358edaf Mon Sep 17 00:00:00 2001 From: Tim Lappe Date: Wed, 4 Jun 2025 07:23:31 +0200 Subject: [PATCH] Refactoring DDD approach --- migrations/Version20250604043747.php | 36 +++++++++++++ migrations/Version20250604051942.php | 29 +++++++++++ src/Application/Commands/AIClientCommand.php | 4 +- src/Application/Commands/LoadFixtures.php | 38 +++++++------- src/Domain/AI/AIClient.php | 4 +- .../ContentManagement/CarPropertyEmbedder.php | 14 ++--- src/Domain/Model/Brand.php | 2 +- src/Domain/Model/CarModel.php | 4 +- src/Domain/Model/CarProperty.php | 6 ++- src/Domain/Model/CarPropertyType.php | 6 --- src/Domain/Model/CarRevision.php | 6 ++- .../Model/{AI => Embedding}/Embedding.php | 4 +- .../LargeEmbeddingVector.php | 2 +- .../SmallEmbeddingVector.php | 2 +- src/Domain/Model/EmbeddingCollection.php | 2 +- src/Domain/Model/{Value => Id}/BrandId.php | 2 +- src/Domain/Model/{Value => Id}/CarModelId.php | 2 +- .../Model/{Value => Id}/CarPropertyId.php | 2 +- .../Model/{Value => Id}/CarRevisionId.php | 2 +- .../Model/{Value => Id}/EmbeddingId.php | 4 +- src/Domain/Model/Id/EmbeddingIdCollection.php | 30 +++++++++++ src/Domain/Repository/BrandRepository.php | 2 +- src/Domain/Repository/CarModelRepository.php | 4 +- .../Repository/CarPropertyRepository.php | 4 +- .../Repository/CarRevisionRepository.php | 4 +- src/Domain/Repository/EmbeddingRepository.php | 13 +++-- src/Domain/Search/Engine.php | 2 +- src/Domain/Search/TileBuilder.php | 2 +- .../Search/TileBuilders/CarTileBuilder.php | 10 +--- .../BrandRepository/ModelMapper.php | 2 +- ...dRepository.php => SqlBrandRepository.php} | 4 +- .../CarModelRepository/ModelMapper.php | 4 +- ...pository.php => SqlCarModelRepository.php} | 6 +-- ...itory.php => SqlCarPropertyRepository.php} | 48 +++++++++++------ .../CarRevisionRepository/ModelMapper.php | 7 ++- ...itory.php => SqlCarRevisionRepository.php} | 11 ++-- ...ository.php => SqlEmbeddingRepository.php} | 52 ++++++++++++++----- templates/home/index.html.twig | 20 ------- 38 files changed, 259 insertions(+), 137 deletions(-) create mode 100644 migrations/Version20250604043747.php create mode 100644 migrations/Version20250604051942.php rename src/Domain/Model/{AI => Embedding}/Embedding.php (89%) rename src/Domain/Model/{AI => Embedding}/LargeEmbeddingVector.php (90%) rename src/Domain/Model/{AI => Embedding}/SmallEmbeddingVector.php (90%) rename src/Domain/Model/{Value => Id}/BrandId.php (94%) rename src/Domain/Model/{Value => Id}/CarModelId.php (94%) rename src/Domain/Model/{Value => Id}/CarPropertyId.php (94%) rename src/Domain/Model/{Value => Id}/CarRevisionId.php (94%) rename src/Domain/Model/{Value => Id}/EmbeddingId.php (94%) create mode 100644 src/Domain/Model/Id/EmbeddingIdCollection.php rename src/Infrastructure/PostgreSQL/Repository/BrandRepository/{PostgreSQLBrandRepository.php => SqlBrandRepository.php} (95%) rename src/Infrastructure/PostgreSQL/Repository/CarModelRepository/{PostgreSQLCarModelRepository.php => SqlCarModelRepository.php} (95%) rename src/Infrastructure/PostgreSQL/Repository/CarPropertyRepository/{PostgreSQLCarPropertyRepository.php => SqlCarPropertyRepository.php} (63%) rename src/Infrastructure/PostgreSQL/Repository/CarRevisionRepository/{PostgreSQLCarRevisionRepository.php => SqlCarRevisionRepository.php} (86%) rename src/Infrastructure/PostgreSQL/Repository/EmbeddingRepository/{PostgreSQLEmbeddingRepository.php => SqlEmbeddingRepository.php} (75%) diff --git a/migrations/Version20250604043747.php b/migrations/Version20250604043747.php new file mode 100644 index 0000000..a1ca2cc --- /dev/null +++ b/migrations/Version20250604043747.php @@ -0,0 +1,36 @@ +addSql('CREATE TABLE car_properties_embeddings ( + car_property_id VARCHAR(255) NOT NULL, + embedding_id VARCHAR(255) NOT NULL, + PRIMARY KEY(car_property_id, embedding_id) + )'); + + $this->addSql('ALTER TABLE car_properties_embeddings ADD CONSTRAINT FK_car_properties_embeddings_car_property_id FOREIGN KEY (car_property_id) REFERENCES car_properties (id)'); + $this->addSql('ALTER TABLE car_properties_embeddings ADD CONSTRAINT FK_car_properties_embeddings_embedding_id FOREIGN KEY (embedding_id) REFERENCES embeddings (id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE car_properties_embeddings'); + } +} diff --git a/migrations/Version20250604051942.php b/migrations/Version20250604051942.php new file mode 100644 index 0000000..dbbc238 --- /dev/null +++ b/migrations/Version20250604051942.php @@ -0,0 +1,29 @@ +addSql('ALTER TABLE car_revisions ADD COLUMN image TEXT'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE car_revisions DROP COLUMN image'); + } +} diff --git a/src/Application/Commands/AIClientCommand.php b/src/Application/Commands/AIClientCommand.php index 50aa3ce..94cc779 100644 --- a/src/Application/Commands/AIClientCommand.php +++ b/src/Application/Commands/AIClientCommand.php @@ -5,8 +5,8 @@ namespace App\Application\Commands; use App\Domain\AI\AIClient; use App\Domain\Model\Persistence\PersistedEmbedding; use App\Domain\Repository\EmbeddingRepository; -use App\Domain\Model\AI\Embedding; -use App\Domain\Model\Value\EmbeddingId; +use App\Domain\Model\Embedding\Embedding; +use App\Domain\Model\Id\EmbeddingId; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; diff --git a/src/Application/Commands/LoadFixtures.php b/src/Application/Commands/LoadFixtures.php index 36c721e..5595f2d 100644 --- a/src/Application/Commands/LoadFixtures.php +++ b/src/Application/Commands/LoadFixtures.php @@ -10,10 +10,10 @@ 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\Value\BrandId; -use App\Domain\Model\Value\CarModelId; -use App\Domain\Model\Value\CarRevisionId; -use App\Domain\Model\Value\CarPropertyId; +use App\Domain\Model\Id\BrandId; +use App\Domain\Model\Id\CarModelId; +use App\Domain\Model\Id\CarRevisionId; +use App\Domain\Model\Id\CarPropertyId; use App\Domain\Model\Battery\CellChemistry; use App\Domain\Model\CarProperty; use App\Domain\Model\CarPropertyType; @@ -75,10 +75,8 @@ class LoadFixtures extends Command $io->title('Loading EV Wiki Fixtures'); $fixtures = $this->getFixtures(); - // Extract unique brands $brandNames = array_unique(array_column($fixtures, 'brand')); - // Load brands $io->section('Loading Brands'); $io->progressStart(count($brandNames)); @@ -119,7 +117,6 @@ class LoadFixtures extends Command $io->progressFinish(); $io->success(sprintf('Successfully loaded %d car models', count($models))); - // Count total revisions for progress bar $totalRevisions = 0; foreach ($fixtures as $brandFixture) { foreach ($brandFixture['models'] as $modelFixture) { @@ -127,7 +124,6 @@ class LoadFixtures extends Command } } - // Load car revisions and properties $io->section('Loading Car Revisions and Properties'); $io->progressStart($totalRevisions); @@ -138,16 +134,17 @@ class LoadFixtures extends Command $carModel = $this->carModels[$modelFixture['model']]; foreach ($modelFixture['revisions'] as $revisionFixture) { - // Create car revision + $image = $revisionFixture['image']; + $carRevision = new CarRevision( CarRevisionId::generate(), $carModel->carModelId, - $revisionFixture['revision'] + $revisionFixture['revision'], + $image ); $this->carRevisionRepository->save($carRevision); - // Create properties foreach ($revisionFixture['properties'] as $propertyData) { $property = new CarProperty( CarPropertyId::generate(), @@ -155,8 +152,9 @@ class LoadFixtures extends Command $propertyData['type'], $propertyData['value'] ); - $this->carPropertyRepository->save($property); + $this->carPropertyEmbedder->createPersistedEmbedding($property, $carRevision, $brand); + $this->carPropertyRepository->save($property); } $io->progressAdvance(); @@ -172,7 +170,7 @@ class LoadFixtures extends Command } /** - * @return array}>}>}> + * @return array}>}>}> */ private function getFixtures(): array { @@ -185,6 +183,7 @@ class LoadFixtures extends Command 'revisions' => [ [ 'revision' => 'Plaid', + 'image' => new Image(externalPublicUrl: 'https://digitalassets.tesla.com/tesla-contents/image/upload/f_auto,q_auto/Model-S-Main-Hero-Desktop-LHD.jpg'), 'properties' => [ ['type' => CarPropertyType::PRODUCTION_BEGIN, 'value' => new Date(1, 1, 2021)], ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_POWER, 'value' => new Power(750)], @@ -200,7 +199,6 @@ class LoadFixtures extends Command ['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')] ] ] ] @@ -210,6 +208,7 @@ class LoadFixtures extends Command 'revisions' => [ [ 'revision' => 'Long Range', + 'image' => new Image(externalPublicUrl: 'https://digitalassets.tesla.com/tesla-contents/image/upload/f_auto,q_auto/Model-3-Main-Hero-Desktop-LHD.jpg'), 'properties' => [ ['type' => CarPropertyType::PRODUCTION_BEGIN, 'value' => new Date(1, 1, 2020)], ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_POWER, 'value' => new Power(366)], @@ -225,7 +224,6 @@ class LoadFixtures extends Command ['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')] ] ] ] @@ -240,6 +238,7 @@ class LoadFixtures extends Command 'revisions' => [ [ 'revision' => 'xDrive50', + 'image' => new Image(externalPublicUrl: 'https://www.bmw.de/content/dam/bmw/common/all-models/x-series/ix/2021/highlights/bmw-ix-sp-desktop.jpg'), 'properties' => [ ['type' => CarPropertyType::PRODUCTION_BEGIN, 'value' => new Date(1, 1, 2021)], ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_POWER, 'value' => new Power(385)], @@ -255,7 +254,6 @@ class LoadFixtures extends Command ['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')] ] ] ] @@ -270,6 +268,7 @@ class LoadFixtures extends Command 'revisions' => [ [ 'revision' => 'RS', + 'image' => 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'), 'properties' => [ ['type' => CarPropertyType::PRODUCTION_BEGIN, 'value' => new Date(1, 1, 2021)], ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_POWER, 'value' => new Power(475)], @@ -285,7 +284,6 @@ class LoadFixtures extends Command ['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')] ] ] ] @@ -300,6 +298,7 @@ class LoadFixtures extends Command 'revisions' => [ [ 'revision' => '450+', + 'image' => 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'), 'properties' => [ ['type' => CarPropertyType::PRODUCTION_BEGIN, 'value' => new Date(1, 1, 2021)], ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_POWER, 'value' => new Power(245)], @@ -315,7 +314,6 @@ class LoadFixtures extends Command ['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')] ] ] ] @@ -330,6 +328,7 @@ class LoadFixtures extends Command 'revisions' => [ [ 'revision' => 'Pro', + 'image' => 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'), 'properties' => [ ['type' => CarPropertyType::PRODUCTION_BEGIN, 'value' => new Date(1, 1, 2020)], ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_POWER, 'value' => new Power(150)], @@ -345,7 +344,6 @@ class LoadFixtures extends Command ['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')] ] ] ] @@ -360,6 +358,7 @@ class LoadFixtures extends Command 'revisions' => [ [ 'revision' => 'Turbo S', + 'image' => 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'), 'properties' => [ ['type' => CarPropertyType::PRODUCTION_BEGIN, 'value' => new Date(1, 1, 2019)], ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_POWER, 'value' => new Power(560)], @@ -375,7 +374,6 @@ class LoadFixtures extends Command ['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')] ] ] ] diff --git a/src/Domain/AI/AIClient.php b/src/Domain/AI/AIClient.php index 3dbb067..b86c7db 100644 --- a/src/Domain/AI/AIClient.php +++ b/src/Domain/AI/AIClient.php @@ -2,8 +2,8 @@ namespace App\Domain\AI; -use App\Domain\Model\AI\LargeEmbeddingVector; -use App\Domain\Model\AI\SmallEmbeddingVector; +use App\Domain\Model\Embedding\LargeEmbeddingVector; +use App\Domain\Model\Embedding\SmallEmbeddingVector; use App\Domain\Model\Value\Vector; use OpenAI; diff --git a/src/Domain/ContentManagement/CarPropertyEmbedder.php b/src/Domain/ContentManagement/CarPropertyEmbedder.php index 3cdcfe4..4cf98bd 100644 --- a/src/Domain/ContentManagement/CarPropertyEmbedder.php +++ b/src/Domain/ContentManagement/CarPropertyEmbedder.php @@ -3,12 +3,11 @@ namespace App\Domain\ContentManagement; use App\Domain\AI\AIClient; -use App\Domain\Model\AI\Embedding; +use App\Domain\Model\Embedding\Embedding; use App\Domain\Model\Brand; use App\Domain\Model\CarProperty; use App\Domain\Model\CarRevision; -use App\Domain\Model\Persistence\PersistedEmbedding; -use App\Domain\Model\Value\EmbeddingId; +use App\Domain\Model\Id\EmbeddingId; use App\Domain\Repository\EmbeddingRepository; use Stringable; @@ -34,9 +33,10 @@ class CarPropertyEmbedder $text .= ', ' . $brand->name; } - $persistedEmbedding = $this->embeddingRepository->findByPhrase($text); - if ($persistedEmbedding) { - return $persistedEmbedding; + $embedding = $this->embeddingRepository->findByPhrase($text); + if ($embedding) { + $carProperty->embeddings->add($embedding->embeddingId); + return $embedding; } $largeEmbedding = $this->aiClient->embedTextLarge($text); @@ -45,6 +45,8 @@ class CarPropertyEmbedder $embedding = new Embedding(EmbeddingId::generate(), $text, $largeEmbedding, $smallEmbedding); $this->embeddingRepository->save($embedding); + $carProperty->embeddings->add($embedding->embeddingId); + return $embedding; } } \ No newline at end of file diff --git a/src/Domain/Model/Brand.php b/src/Domain/Model/Brand.php index 07f0750..0163a65 100644 --- a/src/Domain/Model/Brand.php +++ b/src/Domain/Model/Brand.php @@ -2,7 +2,7 @@ namespace App\Domain\Model; -use App\Domain\Model\Value\BrandId; +use App\Domain\Model\Id\BrandId; final readonly class Brand { diff --git a/src/Domain/Model/CarModel.php b/src/Domain/Model/CarModel.php index 38da43b..8486562 100644 --- a/src/Domain/Model/CarModel.php +++ b/src/Domain/Model/CarModel.php @@ -2,8 +2,8 @@ namespace App\Domain\Model; -use App\Domain\Model\Value\BrandId; -use App\Domain\Model\Value\CarModelId; +use App\Domain\Model\Id\BrandId; +use App\Domain\Model\Id\CarModelId; class CarModel { diff --git a/src/Domain/Model/CarProperty.php b/src/Domain/Model/CarProperty.php index a761ab8..7f1f147 100644 --- a/src/Domain/Model/CarProperty.php +++ b/src/Domain/Model/CarProperty.php @@ -2,8 +2,9 @@ namespace App\Domain\Model; -use App\Domain\Model\Value\CarPropertyId; -use App\Domain\Model\Value\CarRevisionId; +use App\Domain\Model\Id\CarPropertyId; +use App\Domain\Model\Id\CarRevisionId; +use App\Domain\Model\Id\EmbeddingIdCollection; final readonly class CarProperty { @@ -12,5 +13,6 @@ final readonly class CarProperty public readonly CarRevisionId $carRevisionId, public CarPropertyType $type, public mixed $value, + public readonly EmbeddingIdCollection $embeddings = new EmbeddingIdCollection([]), ) {} } \ No newline at end of file diff --git a/src/Domain/Model/CarPropertyType.php b/src/Domain/Model/CarPropertyType.php index 45913c8..e2f69c8 100644 --- a/src/Domain/Model/CarPropertyType.php +++ b/src/Domain/Model/CarPropertyType.php @@ -53,10 +53,6 @@ enum CarPropertyType: string case CATALOG_PRICE_CURRENCY = 'catalog_price_currency'; case CATALOG_PRICE_INCLUDES_VAT = 'catalog_price_includes_vat'; - // Image fields - case IMAGE_EXTERNAL_PUBLIC_URL = 'image_external_public_url'; - case IMAGE_RELATIVE_PUBLIC_URL = 'image_relative_public_url'; - public function humanReadable(): string { return match ($this) { @@ -84,8 +80,6 @@ enum CarPropertyType: string 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', diff --git a/src/Domain/Model/CarRevision.php b/src/Domain/Model/CarRevision.php index d20a422..5597814 100644 --- a/src/Domain/Model/CarRevision.php +++ b/src/Domain/Model/CarRevision.php @@ -2,8 +2,9 @@ namespace App\Domain\Model; -use App\Domain\Model\Value\CarModelId; -use App\Domain\Model\Value\CarRevisionId; +use App\Domain\Model\Id\CarModelId; +use App\Domain\Model\Id\CarRevisionId; +use App\Domain\Model\Image; final readonly class CarRevision { @@ -11,5 +12,6 @@ final readonly class CarRevision public readonly CarRevisionId $carRevisionId, public readonly CarModelId $carModelId, public readonly string $name, + public readonly ?Image $image = null, ) {} } \ No newline at end of file diff --git a/src/Domain/Model/AI/Embedding.php b/src/Domain/Model/Embedding/Embedding.php similarity index 89% rename from src/Domain/Model/AI/Embedding.php rename to src/Domain/Model/Embedding/Embedding.php index 07623d4..ceff8e3 100644 --- a/src/Domain/Model/AI/Embedding.php +++ b/src/Domain/Model/Embedding/Embedding.php @@ -1,8 +1,8 @@ $values + */ + public function __construct( + public array $values + ) {} + + /** + * @return list + */ + public function array(): array + { + return $this->values; + } + + public function add(EmbeddingId $embeddingId): void + { + if (in_array($embeddingId->value, array_map(fn(EmbeddingId $id) => $id->value, $this->values), true)) { + return; + } + + $this->values[] = $embeddingId; + } +} \ No newline at end of file diff --git a/src/Domain/Repository/BrandRepository.php b/src/Domain/Repository/BrandRepository.php index c566791..cdaf6f2 100644 --- a/src/Domain/Repository/BrandRepository.php +++ b/src/Domain/Repository/BrandRepository.php @@ -5,7 +5,7 @@ namespace App\Domain\Repository; use App\Domain\Model\Brand; use App\Domain\Model\BrandCollection; use App\Domain\Model\CarModel; -use App\Domain\Model\Value\BrandId; +use App\Domain\Model\Id\BrandId; interface BrandRepository { diff --git a/src/Domain/Repository/CarModelRepository.php b/src/Domain/Repository/CarModelRepository.php index ace8158..4cc03e1 100644 --- a/src/Domain/Repository/CarModelRepository.php +++ b/src/Domain/Repository/CarModelRepository.php @@ -5,8 +5,8 @@ namespace App\Domain\Repository; use App\Domain\Model\CarModel; use App\Domain\Model\CarModelCollection; use App\Domain\Model\CarRevision; -use App\Domain\Model\Value\CarModelId; -use App\Domain\Model\Value\BrandId; +use App\Domain\Model\Id\CarModelId; +use App\Domain\Model\Id\BrandId; interface CarModelRepository { diff --git a/src/Domain/Repository/CarPropertyRepository.php b/src/Domain/Repository/CarPropertyRepository.php index a561119..acc1791 100644 --- a/src/Domain/Repository/CarPropertyRepository.php +++ b/src/Domain/Repository/CarPropertyRepository.php @@ -5,8 +5,8 @@ namespace App\Domain\Repository; use App\Domain\Model\CarProperty; use App\Domain\Model\CarPropertyCollection; use App\Domain\Model\CarRevision; -use App\Domain\Model\Value\CarRevisionId; -use App\Domain\Model\Value\EmbeddingId; +use App\Domain\Model\Id\CarRevisionId; +use App\Domain\Model\Id\EmbeddingId; interface CarPropertyRepository { diff --git a/src/Domain/Repository/CarRevisionRepository.php b/src/Domain/Repository/CarRevisionRepository.php index e8eb25a..9171bd0 100644 --- a/src/Domain/Repository/CarRevisionRepository.php +++ b/src/Domain/Repository/CarRevisionRepository.php @@ -4,8 +4,8 @@ namespace App\Domain\Repository; use App\Domain\Model\CarRevision; use App\Domain\Model\CarRevisionCollection; -use App\Domain\Model\Value\CarRevisionId; -use App\Domain\Model\Value\CarModelId; +use App\Domain\Model\Id\CarRevisionId; +use App\Domain\Model\Id\CarModelId; interface CarRevisionRepository { diff --git a/src/Domain/Repository/EmbeddingRepository.php b/src/Domain/Repository/EmbeddingRepository.php index 247b843..ee9d71e 100644 --- a/src/Domain/Repository/EmbeddingRepository.php +++ b/src/Domain/Repository/EmbeddingRepository.php @@ -2,10 +2,11 @@ namespace App\Domain\Repository; -use App\Domain\Model\AI\Embedding; -use App\Domain\Model\AI\LargeEmbeddingVector; -use App\Domain\Model\AI\SmallEmbeddingVector; +use App\Domain\Model\Embedding\Embedding; +use App\Domain\Model\Embedding\LargeEmbeddingVector; +use App\Domain\Model\Embedding\SmallEmbeddingVector; use App\Domain\Model\EmbeddingCollection; +use App\Domain\Model\Id\EmbeddingIdCollection; interface EmbeddingRepository { @@ -32,4 +33,10 @@ interface EmbeddingRepository * @return Embedding|null */ public function findByPhrase(string $phrase): ?Embedding; + + /** + * @param EmbeddingIdCollection $embeddingIdCollection + * @return EmbeddingCollection + */ + public function findByEmbeddingIdCollection(EmbeddingIdCollection $embeddingIdCollection): EmbeddingCollection; } \ No newline at end of file diff --git a/src/Domain/Search/Engine.php b/src/Domain/Search/Engine.php index 3235f4c..0f6ad5d 100644 --- a/src/Domain/Search/Engine.php +++ b/src/Domain/Search/Engine.php @@ -3,7 +3,7 @@ namespace App\Domain\Search; use App\Domain\AI\AIClient; -use App\Domain\Model\AI\Embedding; +use App\Domain\Model\Embedding\Embedding; use App\Domain\Repository\CarPropertyRepository; use App\Domain\Repository\EmbeddingRepository; diff --git a/src/Domain/Search/TileBuilder.php b/src/Domain/Search/TileBuilder.php index 36db1ef..5e7390c 100644 --- a/src/Domain/Search/TileBuilder.php +++ b/src/Domain/Search/TileBuilder.php @@ -3,7 +3,7 @@ namespace App\Domain\Search; use App\Domain\Model\CarPropertyCollection; -use App\Domain\Model\Value\CarRevisionId; +use App\Domain\Model\Id\CarRevisionId; use App\Domain\Repository\BrandRepository; use App\Domain\Repository\CarModelRepository; use App\Domain\Repository\CarRevisionRepository; diff --git a/src/Domain/Search/TileBuilders/CarTileBuilder.php b/src/Domain/Search/TileBuilders/CarTileBuilder.php index 0129d71..1c1d29c 100644 --- a/src/Domain/Search/TileBuilders/CarTileBuilder.php +++ b/src/Domain/Search/TileBuilders/CarTileBuilder.php @@ -25,18 +25,12 @@ class CarTileBuilder if ($carProperties->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); - + if ($priceProperty !== null && $accelerationProperty !== null && $carRevision->image !== null) { // Handle Price - it should already be a Price object $priceValue = $priceProperty->value; if (!$priceValue instanceof Price) { @@ -50,7 +44,7 @@ class CarTileBuilder } $subTiles->add(new CarTile( - $image, + $carRevision->image, [ new PriceTile($priceValue), new AccelerationTile($accelerationValue), diff --git a/src/Infrastructure/PostgreSQL/Repository/BrandRepository/ModelMapper.php b/src/Infrastructure/PostgreSQL/Repository/BrandRepository/ModelMapper.php index 38db40f..957b20a 100644 --- a/src/Infrastructure/PostgreSQL/Repository/BrandRepository/ModelMapper.php +++ b/src/Infrastructure/PostgreSQL/Repository/BrandRepository/ModelMapper.php @@ -3,7 +3,7 @@ namespace App\Infrastructure\PostgreSQL\Repository\BrandRepository; use App\Domain\Model\Brand; -use App\Domain\Model\Value\BrandId; +use App\Domain\Model\Id\BrandId; class ModelMapper { diff --git a/src/Infrastructure/PostgreSQL/Repository/BrandRepository/PostgreSQLBrandRepository.php b/src/Infrastructure/PostgreSQL/Repository/BrandRepository/SqlBrandRepository.php similarity index 95% rename from src/Infrastructure/PostgreSQL/Repository/BrandRepository/PostgreSQLBrandRepository.php rename to src/Infrastructure/PostgreSQL/Repository/BrandRepository/SqlBrandRepository.php index 8a0c4f6..fa47055 100644 --- a/src/Infrastructure/PostgreSQL/Repository/BrandRepository/PostgreSQLBrandRepository.php +++ b/src/Infrastructure/PostgreSQL/Repository/BrandRepository/SqlBrandRepository.php @@ -5,11 +5,11 @@ namespace App\Infrastructure\PostgreSQL\Repository\BrandRepository; use App\Domain\Model\Brand; use App\Domain\Model\BrandCollection; use App\Domain\Model\CarModel; -use App\Domain\Model\Value\BrandId; +use App\Domain\Model\Id\BrandId; use App\Domain\Repository\BrandRepository; use Doctrine\DBAL\Connection; -final class PostgreSQLBrandRepository implements BrandRepository +final class SqlBrandRepository implements BrandRepository { public function __construct( private readonly Connection $connection, diff --git a/src/Infrastructure/PostgreSQL/Repository/CarModelRepository/ModelMapper.php b/src/Infrastructure/PostgreSQL/Repository/CarModelRepository/ModelMapper.php index 7efa6bf..32a4597 100644 --- a/src/Infrastructure/PostgreSQL/Repository/CarModelRepository/ModelMapper.php +++ b/src/Infrastructure/PostgreSQL/Repository/CarModelRepository/ModelMapper.php @@ -4,8 +4,8 @@ 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; +use App\Domain\Model\Id\BrandId; +use App\Domain\Model\Id\CarModelId; class ModelMapper { diff --git a/src/Infrastructure/PostgreSQL/Repository/CarModelRepository/PostgreSQLCarModelRepository.php b/src/Infrastructure/PostgreSQL/Repository/CarModelRepository/SqlCarModelRepository.php similarity index 95% rename from src/Infrastructure/PostgreSQL/Repository/CarModelRepository/PostgreSQLCarModelRepository.php rename to src/Infrastructure/PostgreSQL/Repository/CarModelRepository/SqlCarModelRepository.php index 0f6dae0..8e290ff 100644 --- a/src/Infrastructure/PostgreSQL/Repository/CarModelRepository/PostgreSQLCarModelRepository.php +++ b/src/Infrastructure/PostgreSQL/Repository/CarModelRepository/SqlCarModelRepository.php @@ -5,12 +5,12 @@ namespace App\Infrastructure\PostgreSQL\Repository\CarModelRepository; use App\Domain\Model\CarModel; use App\Domain\Model\CarModelCollection; use App\Domain\Model\CarRevision; -use App\Domain\Model\Value\CarModelId; -use App\Domain\Model\Value\BrandId; +use App\Domain\Model\Id\CarModelId; +use App\Domain\Model\Id\BrandId; use App\Domain\Repository\CarModelRepository; use Doctrine\DBAL\Connection; -final class PostgreSQLCarModelRepository implements CarModelRepository +final class SqlCarModelRepository implements CarModelRepository { public function __construct( private readonly Connection $connection, diff --git a/src/Infrastructure/PostgreSQL/Repository/CarPropertyRepository/PostgreSQLCarPropertyRepository.php b/src/Infrastructure/PostgreSQL/Repository/CarPropertyRepository/SqlCarPropertyRepository.php similarity index 63% rename from src/Infrastructure/PostgreSQL/Repository/CarPropertyRepository/PostgreSQLCarPropertyRepository.php rename to src/Infrastructure/PostgreSQL/Repository/CarPropertyRepository/SqlCarPropertyRepository.php index f3d1a75..e65c242 100644 --- a/src/Infrastructure/PostgreSQL/Repository/CarPropertyRepository/PostgreSQLCarPropertyRepository.php +++ b/src/Infrastructure/PostgreSQL/Repository/CarPropertyRepository/SqlCarPropertyRepository.php @@ -6,12 +6,12 @@ use App\Domain\Model\CarProperty; use App\Domain\Model\CarPropertyCollection; use App\Domain\Model\CarPropertyType; use App\Domain\Model\CarRevision; -use App\Domain\Model\Value\CarPropertyId; -use App\Domain\Model\Value\CarRevisionId; +use App\Domain\Model\Id\CarPropertyId; +use App\Domain\Model\Id\CarRevisionId; use App\Domain\Repository\CarPropertyRepository; use Doctrine\DBAL\Connection; -final class PostgreSQLCarPropertyRepository implements CarPropertyRepository +final class SqlCarPropertyRepository implements CarPropertyRepository { public function __construct( private readonly Connection $connection, @@ -50,8 +50,9 @@ final class PostgreSQLCarPropertyRepository implements CarPropertyRepository $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", + INNER JOIN car_properties_embeddings cpe ON cpe.car_property_id = cp.id + INNER JOIN embeddings e ON e.id = cpe.embedding_id + WHERE e.phrase_hash IN ($placeholders)", array_values($phraseHashes) ); @@ -68,15 +69,29 @@ final class PostgreSQLCarPropertyRepository implements CarPropertyRepository 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), - ] - ); + $this->connection->transactional(function (Connection $connection) use ($carProperty) { + $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), + ] + ); + + $connection->executeStatement( + 'DELETE FROM car_properties_embeddings WHERE car_property_id = ?', + [$carProperty->carPropertyId->value] + ); + + foreach ($carProperty->embeddings->array() as $embeddingId) { + $connection->executeStatement( + 'INSERT INTO car_properties_embeddings (car_property_id, embedding_id) VALUES (?, ?) ON CONFLICT DO NOTHING', + [$carProperty->carPropertyId->value, $embeddingId->value] + ); + } + }); } public function delete(CarProperty $carProperty): void @@ -89,7 +104,10 @@ final class PostgreSQLCarPropertyRepository implements CarPropertyRepository public function deleteAll(): void { - $this->connection->executeStatement('DELETE FROM car_properties'); + $this->connection->transactional(function (Connection $connection) { + $connection->executeStatement('DELETE FROM car_properties_embeddings'); + $connection->executeStatement('DELETE FROM car_properties'); + }); } /** diff --git a/src/Infrastructure/PostgreSQL/Repository/CarRevisionRepository/ModelMapper.php b/src/Infrastructure/PostgreSQL/Repository/CarRevisionRepository/ModelMapper.php index 51f3d1e..a6f5461 100644 --- a/src/Infrastructure/PostgreSQL/Repository/CarRevisionRepository/ModelMapper.php +++ b/src/Infrastructure/PostgreSQL/Repository/CarRevisionRepository/ModelMapper.php @@ -3,8 +3,9 @@ namespace App\Infrastructure\PostgreSQL\Repository\CarRevisionRepository; use App\Domain\Model\CarRevision; -use App\Domain\Model\Value\CarModelId; -use App\Domain\Model\Value\CarRevisionId; +use App\Domain\Model\Id\CarModelId; +use App\Domain\Model\Id\CarRevisionId; +use App\Domain\Model\Image; class ModelMapper { @@ -15,11 +16,13 @@ class ModelMapper { $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'); + $image = is_string($row['image'] ?? null) ? new Image(externalPublicUrl: $row['image']) : null; return new CarRevision( carRevisionId: new CarRevisionId($carRevisionId), carModelId: new CarModelId($carModelId), name: is_string($row['name'] ?? null) ? $row['name'] : throw new \InvalidArgumentException('CarRevision name is required'), + image: $image, ); } } \ No newline at end of file diff --git a/src/Infrastructure/PostgreSQL/Repository/CarRevisionRepository/PostgreSQLCarRevisionRepository.php b/src/Infrastructure/PostgreSQL/Repository/CarRevisionRepository/SqlCarRevisionRepository.php similarity index 86% rename from src/Infrastructure/PostgreSQL/Repository/CarRevisionRepository/PostgreSQLCarRevisionRepository.php rename to src/Infrastructure/PostgreSQL/Repository/CarRevisionRepository/SqlCarRevisionRepository.php index d58f214..099dcbe 100644 --- a/src/Infrastructure/PostgreSQL/Repository/CarRevisionRepository/PostgreSQLCarRevisionRepository.php +++ b/src/Infrastructure/PostgreSQL/Repository/CarRevisionRepository/SqlCarRevisionRepository.php @@ -4,12 +4,12 @@ namespace App\Infrastructure\PostgreSQL\Repository\CarRevisionRepository; use App\Domain\Model\CarRevision; use App\Domain\Model\CarRevisionCollection; -use App\Domain\Model\Value\CarRevisionId; -use App\Domain\Model\Value\CarModelId; +use App\Domain\Model\Id\CarRevisionId; +use App\Domain\Model\Id\CarModelId; use App\Domain\Repository\CarRevisionRepository; use Doctrine\DBAL\Connection; -final class PostgreSQLCarRevisionRepository implements CarRevisionRepository +final class SqlCarRevisionRepository implements CarRevisionRepository { public function __construct( private readonly Connection $connection, @@ -62,12 +62,13 @@ final class PostgreSQLCarRevisionRepository implements CarRevisionRepository public function save(CarRevision $carRevision): void { - $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'; + $sql = 'INSERT INTO car_revisions (id, car_model_id, name, image) VALUES (?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET car_model_id = EXCLUDED.car_model_id, name = EXCLUDED.name, image = EXCLUDED.image'; $this->connection->executeStatement($sql, [ $carRevision->carRevisionId->value, $carRevision->carModelId->value, - $carRevision->name + $carRevision->name, + $carRevision->image?->externalPublicUrl, ]); return; diff --git a/src/Infrastructure/PostgreSQL/Repository/EmbeddingRepository/PostgreSQLEmbeddingRepository.php b/src/Infrastructure/PostgreSQL/Repository/EmbeddingRepository/SqlEmbeddingRepository.php similarity index 75% rename from src/Infrastructure/PostgreSQL/Repository/EmbeddingRepository/PostgreSQLEmbeddingRepository.php rename to src/Infrastructure/PostgreSQL/Repository/EmbeddingRepository/SqlEmbeddingRepository.php index 3a56023..811b0e7 100644 --- a/src/Infrastructure/PostgreSQL/Repository/EmbeddingRepository/PostgreSQLEmbeddingRepository.php +++ b/src/Infrastructure/PostgreSQL/Repository/EmbeddingRepository/SqlEmbeddingRepository.php @@ -4,14 +4,15 @@ namespace App\Infrastructure\PostgreSQL\Repository\EmbeddingRepository; use Doctrine\DBAL\Connection; use App\Domain\Repository\EmbeddingRepository; -use App\Domain\Model\AI\Embedding; -use App\Domain\Model\AI\LargeEmbeddingVector; -use App\Domain\Model\AI\SmallEmbeddingVector; +use App\Domain\Model\Embedding\Embedding; +use App\Domain\Model\Embedding\LargeEmbeddingVector; +use App\Domain\Model\Embedding\SmallEmbeddingVector; use App\Domain\Model\EmbeddingCollection; use App\Domain\Model\Value\Vector; -use App\Domain\Model\Value\EmbeddingId; +use App\Domain\Model\Id\EmbeddingId; +use App\Domain\Model\Id\EmbeddingIdCollection; -final class PostgreSQLEmbeddingRepository implements EmbeddingRepository +final class SqlEmbeddingRepository implements EmbeddingRepository { public function __construct( private readonly Connection $connection, @@ -35,6 +36,13 @@ final class PostgreSQLEmbeddingRepository implements EmbeddingRepository ); } + public function saveAll(EmbeddingCollection $embeddingCollection): void + { + foreach ($embeddingCollection->array() as $embedding) { + $this->save($embedding); + } + } + public function delete(Embedding $embedding): void { $this->connection->executeStatement( @@ -58,23 +66,25 @@ final class PostgreSQLEmbeddingRepository implements EmbeddingRepository return $this->mapRowToEmbedding($row); } - public function searchByLargeEmbeddingVector(LargeEmbeddingVector $embeddingVector, int $limit = 10): EmbeddingCollection + public function searchByLargeEmbeddingVector(LargeEmbeddingVector $embeddingVector, int $limit = 100): EmbeddingCollection { $result = $this->connection->executeQuery( - 'SELECT * + 'SELECT *, large_embedding_vector <=> :embeddingVector AS distance FROM embeddings - WHERE large_embedding_vector IS NOT NULL - ORDER BY large_embedding_vector <=> ? - LIMIT ?', + WHERE large_embedding_vector IS NOT NULL + ORDER BY large_embedding_vector <=> :embeddingVector + LIMIT :limit', [ - '[' . implode(',', $embeddingVector->vector->values) . ']', - $limit, + 'embeddingVector' => '[' . implode(',', $embeddingVector->vector->values) . ']', + 'limit' => $limit, ] ); $embeddings = []; foreach ($result->fetchAllAssociative() as $row) { - $embeddings[] = $this->mapRowToEmbedding($row); + if ($row['distance'] < 0.7) { + $embeddings[] = $this->mapRowToEmbedding($row); + } } return new EmbeddingCollection($embeddings); @@ -102,6 +112,22 @@ final class PostgreSQLEmbeddingRepository implements EmbeddingRepository return new EmbeddingCollection($embeddings); } + public function findByEmbeddingIdCollection(EmbeddingIdCollection $embeddingIdCollection): EmbeddingCollection + { + $ids = implode(',', array_map(fn(EmbeddingId $id) => $id->value, $embeddingIdCollection->array())); + + $result = $this->connection->executeQuery( + "SELECT * FROM embeddings WHERE id IN ($ids)", + ); + + $embeddings = []; + foreach ($result->fetchAllAssociative() as $row) { + $embeddings[] = $this->mapRowToEmbedding($row); + } + + return new EmbeddingCollection($embeddings); + } + public function deleteAll(): void { $this->connection->executeStatement('DELETE FROM embeddings'); diff --git a/templates/home/index.html.twig b/templates/home/index.html.twig index 86a3136..4138587 100644 --- a/templates/home/index.html.twig +++ b/templates/home/index.html.twig @@ -8,24 +8,4 @@ {% include '_components/search.html.twig' %} - -
-

Popular Electric Vehicle Brands

-
- {% for brand in brands %} -
-
{{ brand.name }}
- {% if brand.description %} -
{{ brand.description }}
- {% endif %} -
Founded: {{ brand.foundedYear }}
- {% if brand.headquarters %} -
{{ brand.headquarters }}
- {% endif %} -
- {% else %} -
No brands available
- {% endfor %} -
-
{% endblock %} \ No newline at end of file