[WIP] DDD Refactoring

This commit is contained in:
Tim Lappe 2025-06-02 06:37:37 +02:00
parent a3948fe32e
commit 48d3940288
49 changed files with 1422 additions and 1278 deletions

View File

@ -1,57 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250529155930 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create base tables';
}
public function up(Schema $schema): void
{
$this->addSql(<<<SQL
CREATE TABLE brands (
id VARCHAR(255) NOT NULL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
content JSON NOT NULL
)
SQL);
$this->addSql(<<<SQL
CREATE TABLE car_models (
id VARCHAR(255) NOT NULL PRIMARY KEY,
brand_id VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
content JSON NOT NULL,
FOREIGN KEY (brand_id) REFERENCES brands(id)
)
SQL);
$this->addSql(<<<SQL
CREATE TABLE car_revisions (
id VARCHAR(255) NOT NULL PRIMARY KEY,
car_model_id VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
content JSON NOT NULL,
FOREIGN KEY (car_model_id) REFERENCES car_models(id)
)
SQL);
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE car_revisions');
$this->addSql('DROP TABLE car_models');
$this->addSql('DROP TABLE brands');
}
}

View File

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

View File

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250601120221 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add car models, revisions, properties, and embeddings tables';
}
public function up(Schema $schema): void
{
// Enable pgvector extension
$this->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');
}
}

View File

@ -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;
}
}

View File

@ -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<string, string> */
private array $brandIds = [];
/** @var array<string, string> */
private array $carModelIds = [];
/** @var array<string, Brand> */
private array $brands = [];
/** @var array<string, CarModel> */
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);
$this->carRevisionRepository->create(
$revision,
$this->carModelIds[$modelName]
foreach ($fixtures as $brandFixture) {
$brand = $this->brands[$brandFixture['brand']];
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<array{brand: string, models: array<array{model: string, revisions: array<array{revision: string, properties: array<array{type: CarPropertyType, value: mixed}>}>}>}>
*/
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<array{brand: string, model: CarModel}>
*/
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'),
],
[
'brand' => 'BMW',
'model' => new CarModel('i4'),
],
[
'brand' => 'BMW',
'model' => new CarModel('iX3'),
],
// Audi Models
[
'brand' => 'Audi',
'model' => new CarModel('e-tron GT'),
],
[
'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'),
],
[
'brand' => 'Mercedes-Benz',
'model' => new CarModel('EQC'),
],
[
'brand' => 'Mercedes-Benz',
'model' => new CarModel('EQA'),
],
// Volkswagen Models
[
'brand' => 'Volkswagen',
'model' => new CarModel('ID.4'),
],
[
'brand' => 'Volkswagen',
'model' => new CarModel('ID.3'),
],
[
'brand' => 'Volkswagen',
'model' => new CarModel('ID.Buzz'),
],
// Porsche Models
[
'brand' => 'Porsche',
'model' => new CarModel('Taycan'),
],
[
'brand' => 'Porsche',
'model' => new CarModel('Macan Electric'),
],
];
}
/**
* @return array<array{model: string, revision: CarRevision}>
*/
private function getFixtureCarRevisions(): array
{
return [
// Tesla Model S Plaid
'models' => [
[
'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')
),
'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')]
]
]
]
],
// 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')
),
'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')]
]
]
]
]
]
],
// BMW iX xDrive50
[
'brand' => 'BMW',
'models' => [
[
'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')
),
'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')]
]
]
]
]
]
],
// Audi e-tron GT RS
[
'brand' => 'Audi',
'models' => [
[
'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')
),
'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')]
]
]
]
]
]
],
// Mercedes EQS 450+
[
'brand' => 'Mercedes-Benz',
'models' => [
[
'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')
),
'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')]
]
]
]
]
]
],
// Volkswagen ID.4 Pro
[
'brand' => 'Volkswagen',
'models' => [
[
'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')
),
'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')]
]
]
]
]
]
],
// Porsche Taycan Turbo S
[
'brand' => 'Porsche',
'models' => [
[
'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')
),
],
'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')]
]
]
]
]
]
]
];
}
}

View File

@ -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));
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace App\Domain\ContentManagement;
use App\Domain\AI\AIClient;
use App\Domain\Model\AI\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\Repository\EmbeddingRepository;
use Stringable;
class CarPropertyEmbedder
{
public function __construct(
private readonly AIClient $aiClient,
private readonly EmbeddingRepository $embeddingRepository,
) {
}
public function createPersistedEmbedding(CarProperty $carProperty, ?CarRevision $carRevision, ?Brand $brand): ?Embedding
{
if (!($carProperty->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;
}
}

View File

@ -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);
}
}

View File

@ -2,6 +2,8 @@
namespace App\Domain\Model\Battery;
use Stringable;
enum CellChemistry: string
{
case LithiumIronPhosphate = 'LFP';

View File

@ -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,
) {}
}

View File

@ -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
) {}
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Domain\Model;
use App\Domain\Model\Value\CarPropertyId;
use App\Domain\Model\Value\CarRevisionId;
final readonly class CarProperty
{
public function __construct(
public readonly CarPropertyId $carPropertyId,
public readonly CarRevisionId $carRevisionId,
public CarPropertyType $type,
public mixed $value,
) {}
}

View File

@ -0,0 +1,55 @@
<?php
namespace App\Domain\Model;
final class CarPropertyCollection
{
/**
* @param CarProperty[] $properties
*/
public function __construct(
private array $properties = [],
) {}
public function add(CarProperty $property): void
{
$this->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;
}
}

View File

@ -0,0 +1,101 @@
<?php
namespace App\Domain\Model;
enum CarPropertyType: string
{
case NAME = 'name';
case PRODUCTION_BEGIN = 'production_begin';
case PRODUCTION_END = 'production_end';
// DrivingCharacteristics fields
case DRIVING_CHARACTERISTICS_POWER = 'driving_characteristics_power';
case DRIVING_CHARACTERISTICS_ACCELERATION = 'driving_characteristics_acceleration';
case DRIVING_CHARACTERISTICS_TOP_SPEED = 'driving_characteristics_top_speed';
case DRIVING_CHARACTERISTICS_CONSUMPTION = 'driving_characteristics_consumption';
// BatteryProperties fields
case BATTERY_USABLE_CAPACITY = 'battery_usable_capacity';
case BATTERY_TOTAL_CAPACITY = 'battery_total_capacity';
case BATTERY_CELL_CHEMISTRY = 'battery_cell_chemistry';
case BATTERY_MODEL = 'battery_model';
case BATTERY_MANUFACTURER = 'battery_manufacturer';
// ChargingProperties fields
case CHARGING_TOP_CHARGING_SPEED = 'charging_top_charging_speed';
case CHARGING_CHARGE_CURVE = 'charging_charge_curve';
case CHARGING_CHARGE_TIME_PROPERTIES = 'charging_charge_time_properties';
case CHARGING_CONNECTIVITY = 'charging_connectivity';
// ChargeTimeProperties fields
case CHARGE_TIME_0_TO_100 = 'charge_time_0_to_100';
case CHARGE_TIME_0_TO_70 = 'charge_time_0_to_70';
case CHARGE_TIME_10_TO_70 = 'charge_time_10_to_70';
case CHARGE_TIME_20_TO_70 = 'charge_time_20_to_70';
case CHARGE_TIME_10_TO_80 = 'charge_time_10_to_80';
case CHARGE_TIME_20_TO_80 = 'charge_time_20_to_80';
case CHARGE_TIME_10_TO_90 = 'charge_time_10_to_90';
case CHARGE_TIME_20_TO_90 = 'charge_time_20_to_90';
// ChargingConnectivity fields
case CHARGING_IS_400V = 'charging_is_400v';
case CHARGING_IS_800V = 'charging_is_800v';
case CHARGING_PLUG_AND_CHARGE = 'charging_plug_and_charge';
case CHARGING_CONNECTOR_TYPES = 'charging_connector_types';
// RangeProperties fields
case RANGE_WLTP = 'range_wltp';
case RANGE_NEFZ = 'range_nefz';
case RANGE_REAL_RANGE_TESTS = 'range_real_range_tests';
// Price fields
case CATALOG_PRICE = 'catalog_price';
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) {
self::NAME => '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',
};
}
}

View File

@ -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,
) {}
}

View File

@ -1,18 +0,0 @@
<?php
namespace App\Domain\Model;
use App\Domain\Model\Value\Power;
use App\Domain\Model\Value\Acceleration;
use App\Domain\Model\Value\Consumption;
use App\Domain\Model\Value\Speed;
final readonly class DrivingCharacteristics
{
public function __construct(
public ?Power $power = null,
public ?Acceleration $acceleration = null,
public ?Speed $topSpeed = null,
public ?Consumption $consumption = null,
) {}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Domain\Model;
use App\Domain\Model\AI\Embedding;
class EmbeddingCollection
{
/**
* @param Embedding[] $embeddings
*/
public function __construct(
public readonly array $embeddings,
) {}
/**
* @return Embedding[]
*/
public function array(): array
{
return $this->embeddings;
}
}

View File

@ -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 ?? '';
}
}

View File

@ -1,13 +0,0 @@
<?php
namespace App\Domain\Model\Persistence;
use App\Domain\Model\Brand;
class PersistedBrand
{
public function __construct(
public readonly string $id,
public readonly Brand $brand,
) {}
}

View File

@ -1,13 +0,0 @@
<?php
namespace App\Domain\Model\Persistence;
use App\Domain\Model\CarModel;
class PersistedCarModel
{
public function __construct(
public readonly string $id,
public readonly CarModel $carModel,
) {}
}

View File

@ -1,13 +0,0 @@
<?php
namespace App\Domain\Model\Persistence;
use App\Domain\Model\CarRevision;
class PersistedCarRevision
{
public function __construct(
public readonly string $id,
public readonly CarRevision $carRevision,
) {}
}

View File

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

View File

@ -1,19 +0,0 @@
<?php
namespace App\Domain\Model;
use App\Domain\Model\Range\NefzRange;
use App\Domain\Model\Range\WltpRange;
use App\Domain\Model\Range\RealRange;
final readonly class RangeProperties
{
/**
* @param RealRange[] $realRangeTests
*/
public function __construct(
public readonly ?WltpRange $wltp = null,
public readonly ?NefzRange $nefz = null,
public readonly array $realRangeTests = [],
) {}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Domain\Model\Value;
final readonly class BrandId
{
public function __construct(
public string $value
) {
if (!str_starts_with($value, 'brand_')) {
throw new \InvalidArgumentException('BrandId must start with "brand_"');
}
}
public function __toString(): string
{
return $this->value;
}
public function equals(BrandId $other): bool
{
return $this->value === $other->value;
}
public static function generate(): BrandId
{
return new BrandId(uniqid('brand_', true));
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Domain\Model\Value;
final readonly class CarModelId
{
public function __construct(
public string $value
) {
if (!str_starts_with($value, 'carmodel_')) {
throw new \InvalidArgumentException('CarModelId must start with "carmodel_"');
}
}
public function __toString(): string
{
return $this->value;
}
public function equals(CarModelId $other): bool
{
return $this->value === $other->value;
}
public static function generate(): CarModelId
{
return new CarModelId(uniqid('carmodel_', true));
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Domain\Model\Value;
final readonly class CarPropertyId
{
public function __construct(
public string $value
) {
if (!str_starts_with($value, 'carproperty_')) {
throw new \InvalidArgumentException('CarPropertyId must start with "carproperty_"');
}
}
public function __toString(): string
{
return $this->value;
}
public function equals(CarPropertyId $other): bool
{
return $this->value === $other->value;
}
public static function generate(): CarPropertyId
{
return new CarPropertyId(uniqid('carproperty_', true));
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Domain\Model\Value;
final readonly class CarRevisionId
{
public function __construct(
public string $value
) {
if (!str_starts_with($value, 'carrevision_')) {
throw new \InvalidArgumentException('CarRevisionId must start with "carrevision_"');
}
}
public function __toString(): string
{
return $this->value;
}
public function equals(CarRevisionId $other): bool
{
return $this->value === $other->value;
}
public static function generate(): CarRevisionId
{
return new CarRevisionId(uniqid('carrevision_', true));
}
}

View File

@ -2,6 +2,7 @@
namespace App\Domain\Model\Value;
class Consumption
{
public function __construct(

View File

@ -0,0 +1,29 @@
<?php
namespace App\Domain\Model\Value;
final readonly class EmbeddingId
{
public function __construct(
public string $value
) {
if (!str_starts_with($value, 'embedding_')) {
throw new \InvalidArgumentException('EmbeddingId must start with "embedding_"');
}
}
public function __toString(): string
{
return $this->value;
}
public function equals(EmbeddingId $other): bool
{
return $this->value === $other->value;
}
public static function generate(): EmbeddingId
{
return new EmbeddingId(uniqid('embedding_', true));
}
}

View File

@ -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 findByCarModel(CarModel $carModel): ?Brand;
public function update(PersistedBrand $persistedBrand): void;
public function findById(BrandId $brandId): ?Brand;
public function save(Brand $brand): void;
public function deleteAll(): void;
}

View File

@ -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 findByBrandId(BrandId $brandId): CarModelCollection;
public function create(CarModel $carModel, string $brandId): PersistedCarModel;
public function findByCarRevision(CarRevision $carRevision): ?CarModel;
public function update(PersistedCarModel $persistedCarModel): void;
public function save(CarModel $carModel): void;
public function delete(PersistedCarModel $persistedCarModel): void;
public function delete(CarModel $carModel): void;
public function deleteAll(): void;
}

View File

@ -0,0 +1,30 @@
<?php
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;
interface CarPropertyRepository
{
public function findByCarRevision(CarRevision $carRevision): CarPropertyCollection;
/**
* @param EmbeddingId[] $embeddingIds
*/
public function findByEmbeddingIds(array $embeddingIds): CarPropertyCollection;
/**
* @param string[] $phraseHashes
*/
public function findByEmbeddingPhraseHashes(array $phraseHashes): CarPropertyCollection;
public function save(CarProperty $carProperty): void;
public function delete(CarProperty $carProperty): void;
public function deleteAll(): void;
}

View File

@ -4,19 +4,20 @@ namespace App\Domain\Repository;
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;
interface CarRevisionRepository
{
public function findAll(): CarRevisionCollection;
public function findById(string $id): ?PersistedCarRevision;
public function findById(CarRevisionId $carRevisionId): ?CarRevision;
public function findByCarModelId(string $carModelId): CarRevisionCollection;
public function findByCarModelId(CarModelId $carModelId): CarRevisionCollection;
public function create(CarRevision $carRevision, string $carModelId): PersistedCarRevision;
public function save(CarRevision $carRevision): void;
public function update(PersistedCarRevision $persistedCarRevision): void;
public function delete(CarRevision $carRevision): void;
public function delete(PersistedCarRevision $persistedCarRevision): void;
public function deleteAll(): void;
}

View File

@ -5,26 +5,31 @@ namespace App\Domain\Repository;
use App\Domain\Model\AI\Embedding;
use App\Domain\Model\AI\LargeEmbeddingVector;
use App\Domain\Model\AI\SmallEmbeddingVector;
use App\Domain\Model\Persistence\PersistedEmbedding;
use App\Domain\Model\Value\Vector;
use App\Domain\Model\EmbeddingCollection;
interface EmbeddingRepository
{
public function create(Embedding $embedding): PersistedEmbedding;
public function save(Embedding $embedding): void;
public function delete(PersistedEmbedding $persistedEmbedding): void;
public function delete(Embedding $embedding): void;
/**
* @param LargeEmbeddingVector $embeddingVector
* @param int $limit
* @return PersistedEmbedding[]
* @return EmbeddingCollection
*/
public function searchByLargeEmbeddingVector(LargeEmbeddingVector $embeddingVector, int $limit = 10): array;
public function searchByLargeEmbeddingVector(LargeEmbeddingVector $embeddingVector, int $limit = 10): EmbeddingCollection;
/**
* @param SmallEmbeddingVector $vector
* @param int $limit
* @return PersistedEmbedding[]
* @return EmbeddingCollection
*/
public function searchBySmallEmbeddingVector(SmallEmbeddingVector $vector, int $limit = 10): array;
public function searchBySmallEmbeddingVector(SmallEmbeddingVector $vector, int $limit = 10): EmbeddingCollection;
/**
* @param string $phrase
* @return Embedding|null
*/
public function findByPhrase(string $phrase): ?Embedding;
}

View File

@ -2,169 +2,27 @@
namespace App\Domain\Search;
use App\Domain\Model\CarRevision;
use App\Domain\Model\DrivingCharacteristics;
use App\Domain\Model\Image;
use App\Domain\Model\Value\Currency;
use App\Domain\Model\Value\Date;
use App\Domain\Model\Value\Price;
use App\Domain\Model\Value\Range;
use App\Domain\Model\Battery\BatteryProperties;
use App\Domain\Model\Battery\CellChemistry;
use App\Domain\Model\Value\Power;
use App\Domain\Model\Value\Acceleration;
use App\Domain\Model\Value\Consumption;
use App\Domain\Model\Value\Speed;
use App\Domain\Model\Value\Drivetrain;
use App\Domain\Model\Value\Energy;
use App\Domain\Model\Value\ChargingSpeed;
use App\Domain\Model\Charging\ChargingProperties;
use App\Domain\Model\Charging\ChargeCurve;
use App\Domain\Model\Charging\ChargeTimeProperties;
use App\Domain\Model\Charging\ChargingConnectivity;
use App\Domain\Model\Charging\ConnectorType;
use App\Domain\Model\RangeProperties;
use App\Domain\Model\Range\WltpRange;
use App\Domain\Model\Range\NefzRange;
use App\Domain\Model\Range\RealRange;
use App\Domain\Model\Value\Season;
use App\Domain\Search\Tiles\SectionTile;
use App\Domain\Search\Tiles\SubSectionTile;
use App\Domain\Search\Tiles\BrandTile;
use App\Domain\Search\Tiles\PriceTile;
use App\Domain\Search\Tiles\RangeTile;
use App\Domain\Search\Tiles\BatteryTile;
use App\Domain\Search\Tiles\PowerTile;
use App\Domain\Search\Tiles\AccelerationTile;
use App\Domain\Search\Tiles\ChargingTile;
use App\Domain\Search\Tiles\ConsumptionTile;
use App\Domain\Search\Tiles\AvailabilityTile;
use App\Domain\Search\Tiles\CarTile;
use App\Domain\Search\Tiles\TopSpeedTile;
use App\Domain\Search\Tiles\DrivetrainTile;
use App\Domain\Search\Tiles\ChargeTimeTile;
use App\Domain\Search\Tiles\ChargingConnectivityTile;
use App\Domain\Search\Tiles\BatteryDetailsTile;
use App\Domain\Search\Tiles\RealRangeTile;
use App\Domain\AI\AIClient;
use App\Domain\Model\AI\Embedding;
use App\Domain\Repository\CarPropertyRepository;
use App\Domain\Repository\EmbeddingRepository;
class Engine
{
public function __construct(
private readonly EmbeddingRepository $embeddingRepository,
private readonly CarPropertyRepository $carPropertyRepository,
private readonly AIClient $aiClient,
private readonly TileBuilder $tileBuilder,
) {
}
public function search(string $query): TileCollection
{
$batteryProperties = new BatteryProperties(
usableCapacity: new Energy(77.0),
totalCapacity: new Energy(82.0),
cellChemistry: CellChemistry::LithiumNickelManganeseOxide,
model: 'NCM811',
manufacturer: 'LG Energy Solution'
);
$results = $this->embeddingRepository->searchByLargeEmbeddingVector($this->aiClient->embedTextLarge($query));
$chargeCurve = new ChargeCurve(
averagePowerSoc0: new Power(175),
averagePowerSoc10: new Power(175),
averagePowerSoc20: new Power(170),
averagePowerSoc30: new Power(165),
averagePowerSoc40: new Power(155),
averagePowerSoc50: new Power(145),
averagePowerSoc60: new Power(130),
averagePowerSoc70: new Power(110),
averagePowerSoc80: new Power(85),
averagePowerSoc90: new Power(50),
averagePowerSoc100: new Power(20)
);
$carProperties = $this->carPropertyRepository->findByEmbeddingPhraseHashes(array_map(fn (Embedding $embedding) => $embedding->phraseHash(), $results->array()));
$chargeTimeProperties = new ChargeTimeProperties(
minutesFrom0To100: 155,
minutesFrom10To80: 28,
minutesFrom20To80: 25,
minutesFrom10To90: 42
);
$chargingConnectivity = new ChargingConnectivity(
is400v: true,
is800v: false,
plugAndCharge: true,
connectorTypes: [ConnectorType::CCS, ConnectorType::Type2]
);
$chargingProperties = new ChargingProperties(
topChargingSpeed: new Power(175),
chargeCurve: $chargeCurve,
chargeTimeProperties: $chargeTimeProperties,
chargingConnectivity: $chargingConnectivity
);
$drivingCharacteristics = new DrivingCharacteristics(
power: new Power(210),
acceleration: new Acceleration(6.6),
topSpeed: new Speed(180),
consumption: new Consumption(new Energy(17.1))
);
$wltpRange = new WltpRange(new Range(450));
$nefzRange = new NefzRange(new Range(485));
$realRangeTests = [
new RealRange(new Range(380), Season::Winter, new Speed(130)),
new RealRange(new Range(420), Season::Summer, new Speed(120)),
new RealRange(new Range(365), Season::Winter, new Speed(160))
];
$rangeProperties = new RangeProperties(
wltp: $wltpRange,
nefz: $nefzRange,
realRangeTests: $realRangeTests
);
$skodaElroq85 = new CarRevision(
name: 'Skoda Enyaq iV 85',
productionBegin: new Date(1, 1, 2020),
productionEnd: null,
drivingCharacteristics: $drivingCharacteristics,
battery: $batteryProperties,
chargingProperties: $chargingProperties,
rangeProperties: $rangeProperties,
catalogPrice: new Price(43900, Currency::euro()),
image: new Image('https://www.scherer-gruppe.de/media/f3b72d42-4b26-4606-8df4-d840efeff017/01_elroc.jpg?w=1920&h=758&action=crop&scale=both&anchor=middlecenter')
);
$chargingSpeed = new ChargingSpeed(
dcMaxKw: new Power(175),
acMaxKw: new Power(11)
);
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);
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace App\Domain\Search;
use App\Domain\Model\CarPropertyCollection;
use App\Domain\Model\Value\CarRevisionId;
use App\Domain\Repository\BrandRepository;
use App\Domain\Repository\CarModelRepository;
use App\Domain\Repository\CarRevisionRepository;
use App\Domain\Search\TileBuilders\CarTileBuilder;
class TileBuilder
{
private readonly CarTileBuilder $carTileBuilder;
public function __construct(
private readonly CarRevisionRepository $carRevisionRepository,
private readonly CarModelRepository $carModelRepository,
private readonly BrandRepository $brandRepository,
) {
$this->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;
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace App\Domain\Search\TileBuilders;
use App\Domain\Model\Brand;
use App\Domain\Model\CarModel;
use App\Domain\Model\CarPropertyCollection;
use App\Domain\Model\CarPropertyType;
use App\Domain\Model\CarRevision;
use App\Domain\Model\Image;
use App\Domain\Model\Value\Acceleration;
use App\Domain\Model\Value\Price;
use App\Domain\Search\TileCollection;
use App\Domain\Search\Tiles\AccelerationTile;
use App\Domain\Search\Tiles\CarTile;
use App\Domain\Search\Tiles\PowerTile;
use App\Domain\Search\Tiles\PriceTile;
use App\Domain\Search\Tiles\SectionTile;
class CarTileBuilder
{
public function build(Brand $brand, CarModel $carModel, CarRevision $carRevision, CarPropertyCollection $carProperties, TileCollection $tiles): void
{
$subTiles = new TileCollection([]);
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);
// 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()
));
}
}

View File

@ -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;
}
}

View File

@ -1,12 +0,0 @@
<?php
namespace App\Domain\Search\Tiles;
use App\Domain\Model\DrivingCharacteristics;
final readonly class PerformanceOverviewTile
{
public function __construct(
public DrivingCharacteristics $drivingCharacteristics,
) {}
}

View File

@ -1,12 +0,0 @@
<?php
namespace App\Domain\Search\Tiles;
use App\Domain\Model\RangeProperties;
final readonly class RangeComparisonTile
{
public function __construct(
public RangeProperties $rangeProperties,
) {}
}

View File

@ -3,21 +3,20 @@
namespace App\Infrastructure\PostgreSQL\Repository\BrandRepository;
use App\Domain\Model\Brand;
use App\Domain\Model\Value\BrandId;
class ModelMapper
{
/**
* @param array<string, mixed> $data
* @param array<string, mixed> $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'),
);
}
}

View File

@ -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,
]);
$this->connection->executeStatement($sql, [
$brandId,
$brand->name,
$content,
]);
return new PersistedBrand($brandId, $brand);
if ($row === false) {
return null;
}
public function update(PersistedBrand $persistedBrand): void
$mapper = new ModelMapper();
return $mapper->map($row);
}
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');
}
}

View File

@ -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<string, mixed> $data
* @param array<string, mixed> $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'),
);
}
}

View File

@ -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,
]);
$this->connection->executeStatement($sql, [
$carModelId,
$brandId,
$carModel->name,
$content,
]);
return new PersistedCarModel($carModelId, $carModel);
if ($row === false) {
return null;
}
public function update(PersistedCarModel $persistedCarModel): void
$mapper = new ModelMapper();
return $mapper->map($row);
}
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]);
}
public function deleteAll(): void
{
$this->connection->executeStatement('DELETE FROM car_models');
}
}

View File

@ -0,0 +1,121 @@
<?php
namespace App\Infrastructure\PostgreSQL\Repository\CarPropertyRepository;
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\Repository\CarPropertyRepository;
use Doctrine\DBAL\Connection;
final class PostgreSQLCarPropertyRepository implements CarPropertyRepository
{
public function __construct(
private readonly Connection $connection,
) {}
public function findByCarRevision(CarRevision $carRevision): CarPropertyCollection
{
$result = $this->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<string, mixed> $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;
}
}
}

View File

@ -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<string, mixed> $data
* @param array<string, mixed> $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'),
);
}
}

View File

@ -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]);
}
public function deleteAll(): void
{
$this->connection->executeStatement('DELETE FROM car_revisions');
}
}

View File

@ -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(<<<SQL
INSERT INTO embeddings (phrase_hash, phrase, large_embedding_vector, small_embedding_vector)
VALUES (:phrase_hash, :phrase, :large_embedding_vector, :small_embedding_vector)
SQL, [
'phrase_hash' => $hash,
'phrase' => $embedding->phrase,
'large_embedding_vector' => $embedding->largeEmbeddingVector !== null ? '[' . implode(',', $embedding->largeEmbeddingVector->vector->values) . ']' : null,
'small_embedding_vector' => $embedding->smallEmbeddingVector !== null ? '[' . implode(',', $embedding->smallEmbeddingVector->vector->values) . ']' : null,
]);
return new PersistedEmbedding(
$hash,
$embedding,
$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(<<<SQL
SELECT phrase_hash, phrase, large_embedding_vector, small_embedding_vector, created_at
$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 <=> :vector
LIMIT :limit
SQL, [
'vector' => '[' . implode(',', $embeddingVector->vector->values) . ']',
'limit' => $limit,
]);
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(<<<SQL
SELECT phrase_hash, phrase, large_embedding_vector, small_embedding_vector, created_at
$result = $this->connection->executeQuery(
'SELECT *
FROM embeddings
WHERE small_embedding_vector IS NOT NULL
ORDER BY small_embedding_vector <=> :vector
LIMIT :limit
SQL, [
'vector' => '[' . implode(',', $smallEmbeddingVector->vector->values) . ']',
'limit' => $limit,
]);
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<string, mixed> $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,
);
}
}

View File

@ -13,7 +13,7 @@
<h2 class="section-title"><i class="fas fa-industry"></i> Popular Electric Vehicle Brands</h2>
<div class="brands-grid">
{% for brand in brands %}
<div class="brand-card" data-brand-id="{{ brand.id }}">
<div class="brand-card">
<div class="brand-name"><i class="fas fa-car"></i> {{ brand.name }}</div>
{% if brand.description %}
<div class="brand-description">{{ brand.description }}</div>