[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\Model\Persistence\PersistedEmbedding;
use App\Domain\Repository\EmbeddingRepository; use App\Domain\Repository\EmbeddingRepository;
use App\Domain\Model\AI\Embedding; use App\Domain\Model\AI\Embedding;
use App\Domain\Model\AI\LargeEmbeddingVector; use App\Domain\Model\Value\EmbeddingId;
use App\Domain\Model\Value\Vector;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
@ -41,12 +40,12 @@ class AIClientCommand extends Command
$text = is_string($textArg) ? $textArg : ''; $text = is_string($textArg) ? $textArg : '';
if ($input->getOption('embed')) { if ($input->getOption('embed')) {
$persistedEmbedding = $this->embedText($text); $embedding = $this->embedText($text);
$output->writeln($persistedEmbedding->phraseHash); $output->writeln($embedding->phrase);
} else if ($input->getOption('search')) { } else if ($input->getOption('search')) {
$results = $this->embeddingRepository->searchByLargeEmbeddingVector(new LargeEmbeddingVector(new Vector($this->aiClient->embedText($text)))); $results = $this->embeddingRepository->searchByLargeEmbeddingVector($this->aiClient->embedTextLarge($text));
foreach ($results as $result) { foreach ($results->array() as $result) {
$output->writeln($result->embedding->phrase); $output->writeln($result->phrase);
} }
} else { } else {
$output->writeln($this->aiClient->generateText($text)); $output->writeln($this->aiClient->generateText($text));
@ -55,11 +54,12 @@ class AIClientCommand extends Command
return Command::SUCCESS; return Command::SUCCESS;
} }
private function embedText(string $text): PersistedEmbedding private function embedText(string $text): Embedding
{ {
$embedding = $this->aiClient->embedText($text); $embeddingVector = $this->aiClient->embedTextLarge($text);
$vector = new Vector($embedding); $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; namespace App\Application\Commands;
use App\Domain\ContentManagement\CarPropertyEmbedder;
use App\Domain\Model\Brand; use App\Domain\Model\Brand;
use App\Domain\Model\CarModel; use App\Domain\Model\CarModel;
use App\Domain\Model\CarRevision; use App\Domain\Model\CarRevision;
use App\Domain\Model\DrivingCharacteristics;
use App\Domain\Model\Image; use App\Domain\Model\Image;
use App\Domain\Model\Value\Date; use App\Domain\Model\Value\Date;
use App\Domain\Model\Value\Price; use App\Domain\Model\Value\Price;
use App\Domain\Model\Value\Currency; 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\Battery\CellChemistry;
use App\Domain\Model\Charging\ChargingProperties; use App\Domain\Model\CarProperty;
use App\Domain\Model\RangeProperties; use App\Domain\Model\CarPropertyType;
use App\Domain\Model\Range\WltpRange;
use App\Domain\Model\Range\NefzRange;
use App\Domain\Model\Value\Energy; use App\Domain\Model\Value\Energy;
use App\Domain\Model\Value\Power; use App\Domain\Model\Value\Power;
use App\Domain\Model\Value\Acceleration; 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\BrandRepository;
use App\Domain\Repository\CarModelRepository; use App\Domain\Repository\CarModelRepository;
use App\Domain\Repository\CarRevisionRepository; use App\Domain\Repository\CarRevisionRepository;
use App\Domain\Repository\CarPropertyRepository;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Style\SymfonyStyle;
@ -37,485 +40,348 @@ use Symfony\Component\Console\Style\SymfonyStyle;
)] )]
class LoadFixtures extends Command class LoadFixtures extends Command
{ {
/** @var array<string, string> */ /** @var array<string, Brand> */
private array $brandIds = []; private array $brands = [];
/** @var array<string, string> */ /** @var array<string, CarModel> */
private array $carModelIds = []; private array $carModels = [];
public function __construct( public function __construct(
private readonly BrandRepository $brandRepository, private readonly BrandRepository $brandRepository,
private readonly CarModelRepository $carModelRepository, private readonly CarModelRepository $carModelRepository,
private readonly CarRevisionRepository $carRevisionRepository, private readonly CarRevisionRepository $carRevisionRepository,
private readonly CarPropertyRepository $carPropertyRepository,
private readonly CarPropertyEmbedder $carPropertyEmbedder,
) { ) {
parent::__construct(); 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 protected function execute(InputInterface $input, OutputInterface $output): int
{ {
$io = new SymfonyStyle($input, $output); $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'); $io->title('Loading EV Wiki Fixtures');
$fixtures = $this->getFixtures();
// Extract unique brands
$brandNames = array_unique(array_column($fixtures, 'brand'));
// Load brands // Load brands
$brands = $this->getFixtureBrands();
$io->section('Loading Brands'); $io->section('Loading Brands');
$io->progressStart(count($brands)); $io->progressStart(count($brandNames));
foreach ($brands as $brand) { foreach ($brandNames as $brandName) {
$persistedBrand = $this->brandRepository->create($brand); $brand = new Brand(BrandId::generate(), $brandName);
$this->brandIds[$brand->name] = $persistedBrand->id; $this->brandRepository->save($brand);
$this->brands[$brandName] = $brand;
$io->progressAdvance(); $io->progressAdvance();
} }
$io->progressFinish(); $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 // Load car models
$carModels = $this->getFixtureCarModels();
$io->section('Loading Car Models'); $io->section('Loading Car Models');
$io->progressStart(count($carModels)); $io->progressStart(count($models));
foreach ($carModels as $carModelData) { foreach ($models as $modelName => $brandName) {
$model = $carModelData['model']; $carModel = new CarModel(
$brandName = $carModelData['brand']; CarModelId::generate(),
$this->brands[$brandName]->brandId,
$persistedCarModel = $this->carModelRepository->create( $modelName
$model,
$this->brandIds[$brandName]
); );
$this->carModelIds[$model->name] = $persistedCarModel->id;
$this->carModelRepository->save($carModel);
$this->carModels[$modelName] = $carModel;
$io->progressAdvance(); $io->progressAdvance();
} }
$io->progressFinish(); $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 // Count total revisions for progress bar
$carRevisions = $this->getFixtureCarRevisions(); $totalRevisions = 0;
$io->section('Loading Car Revisions'); foreach ($fixtures as $brandFixture) {
$io->progressStart(count($carRevisions)); foreach ($brandFixture['models'] as $modelFixture) {
$totalRevisions += count($modelFixture['revisions']);
}
}
foreach ($carRevisions as $carRevisionData) { // Load car revisions and properties
$revision = $carRevisionData['revision']; $io->section('Loading Car Revisions and Properties');
$modelName = $carRevisionData['model']; $io->progressStart($totalRevisions);
$this->carRevisionRepository->create( foreach ($fixtures as $brandFixture) {
$revision, $brand = $this->brands[$brandFixture['brand']];
$this->carModelIds[$modelName]
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->progressAdvance();
} }
}
}
$io->progressFinish(); $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!'); $io->success('All fixtures loaded successfully!');
return Command::SUCCESS; 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 [ 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', 'brand' => 'Tesla',
'model' => new CarModel('Model S'), 'models' => [
],
[
'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
[ [
'model' => 'Model S', 'model' => 'Model S',
'revision' => new CarRevision( 'revisions' => [
name: 'Plaid', [
productionBegin: new Date(1, 1, 2021), 'revision' => 'Plaid',
drivingCharacteristics: new DrivingCharacteristics( 'properties' => [
power: new Power(750), ['type' => CarPropertyType::PRODUCTION_BEGIN, 'value' => new Date(1, 1, 2021)],
acceleration: new Acceleration(2.1), ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_POWER, 'value' => new Power(750)],
topSpeed: new Speed(322), ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_ACCELERATION, 'value' => new Acceleration(2.1)],
consumption: new Consumption(new Energy(19.3)) ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_TOP_SPEED, 'value' => new Speed(322)],
), ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_CONSUMPTION, 'value' => new Consumption(new Energy(19.3))],
battery: new BatteryProperties( ['type' => CarPropertyType::BATTERY_USABLE_CAPACITY, 'value' => new Energy(95.0)],
usableCapacity: new Energy(95.0), ['type' => CarPropertyType::BATTERY_TOTAL_CAPACITY, 'value' => new Energy(100.0)],
totalCapacity: new Energy(100.0), ['type' => CarPropertyType::BATTERY_CELL_CHEMISTRY, 'value' => CellChemistry::LithiumNickelManganeseOxide],
cellChemistry: CellChemistry::LithiumNickelManganeseOxide, ['type' => CarPropertyType::BATTERY_MODEL, 'value' => '4680'],
model: '4680', ['type' => CarPropertyType::BATTERY_MANUFACTURER, 'value' => 'Tesla'],
manufacturer: 'Tesla' ['type' => CarPropertyType::CHARGING_TOP_CHARGING_SPEED, 'value' => new Power(250)],
), ['type' => CarPropertyType::RANGE_WLTP, 'value' => new Range(628)],
chargingProperties: new ChargingProperties( ['type' => CarPropertyType::RANGE_NEFZ, 'value' => new Range(652)],
topChargingSpeed: new Power(250) ['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')]
rangeProperties: new RangeProperties( ]
wltp: new WltpRange(new Range(628)), ]
nefz: new NefzRange(new Range(652)) ]
),
catalogPrice: new Price(129990, Currency::euro()),
image: new Image('https://digitalassets.tesla.com/tesla-contents/image/upload/f_auto,q_auto/Model-S-Main-Hero-Desktop-LHD.jpg')
),
], ],
// Tesla Model 3 Long Range
[ [
'model' => 'Model 3', 'model' => 'Model 3',
'revision' => new CarRevision( 'revisions' => [
name: 'Long Range', [
productionBegin: new Date(1, 1, 2020), 'revision' => 'Long Range',
drivingCharacteristics: new DrivingCharacteristics( 'properties' => [
power: new Power(366), ['type' => CarPropertyType::PRODUCTION_BEGIN, 'value' => new Date(1, 1, 2020)],
acceleration: new Acceleration(4.4), ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_POWER, 'value' => new Power(366)],
topSpeed: new Speed(233), ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_ACCELERATION, 'value' => new Acceleration(4.4)],
consumption: new Consumption(new Energy(14.9)) ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_TOP_SPEED, 'value' => new Speed(233)],
), ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_CONSUMPTION, 'value' => new Consumption(new Energy(14.9))],
battery: new BatteryProperties( ['type' => CarPropertyType::BATTERY_USABLE_CAPACITY, 'value' => new Energy(75.0)],
usableCapacity: new Energy(75.0), ['type' => CarPropertyType::BATTERY_TOTAL_CAPACITY, 'value' => new Energy(82.0)],
totalCapacity: new Energy(82.0), ['type' => CarPropertyType::BATTERY_CELL_CHEMISTRY, 'value' => CellChemistry::LithiumNickelManganeseOxide],
cellChemistry: CellChemistry::LithiumNickelManganeseOxide, ['type' => CarPropertyType::BATTERY_MODEL, 'value' => '2170'],
model: '2170', ['type' => CarPropertyType::BATTERY_MANUFACTURER, 'value' => 'Tesla'],
manufacturer: 'Tesla' ['type' => CarPropertyType::CHARGING_TOP_CHARGING_SPEED, 'value' => new Power(250)],
), ['type' => CarPropertyType::RANGE_WLTP, 'value' => new Range(602)],
chargingProperties: new ChargingProperties( ['type' => CarPropertyType::RANGE_NEFZ, 'value' => new Range(614)],
topChargingSpeed: new Power(250) ['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')]
rangeProperties: new RangeProperties( ]
wltp: new WltpRange(new Range(602)), ]
nefz: new NefzRange(new Range(614)) ]
), ]
catalogPrice: new Price(49990, Currency::euro()), ]
image: new Image('https://digitalassets.tesla.com/tesla-contents/image/upload/f_auto,q_auto/Model-3-Main-Hero-Desktop-LHD.jpg')
),
], ],
[
// BMW iX xDrive50 'brand' => 'BMW',
'models' => [
[ [
'model' => 'iX', 'model' => 'iX',
'revision' => new CarRevision( 'revisions' => [
name: 'xDrive50', [
productionBegin: new Date(1, 1, 2021), 'revision' => 'xDrive50',
drivingCharacteristics: new DrivingCharacteristics( 'properties' => [
power: new Power(385), ['type' => CarPropertyType::PRODUCTION_BEGIN, 'value' => new Date(1, 1, 2021)],
acceleration: new Acceleration(4.6), ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_POWER, 'value' => new Power(385)],
topSpeed: new Speed(200), ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_ACCELERATION, 'value' => new Acceleration(4.6)],
consumption: new Consumption(new Energy(19.8)) ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_TOP_SPEED, 'value' => new Speed(200)],
), ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_CONSUMPTION, 'value' => new Consumption(new Energy(19.8))],
battery: new BatteryProperties( ['type' => CarPropertyType::BATTERY_USABLE_CAPACITY, 'value' => new Energy(71.2)],
usableCapacity: new Energy(71.2), ['type' => CarPropertyType::BATTERY_TOTAL_CAPACITY, 'value' => new Energy(76.6)],
totalCapacity: new Energy(76.6), ['type' => CarPropertyType::BATTERY_CELL_CHEMISTRY, 'value' => CellChemistry::LithiumNickelManganeseOxide],
cellChemistry: CellChemistry::LithiumNickelManganeseOxide, ['type' => CarPropertyType::BATTERY_MODEL, 'value' => 'BMW Gen5'],
model: 'BMW Gen5', ['type' => CarPropertyType::BATTERY_MANUFACTURER, 'value' => 'CATL'],
manufacturer: 'CATL' ['type' => CarPropertyType::CHARGING_TOP_CHARGING_SPEED, 'value' => new Power(195)],
), ['type' => CarPropertyType::RANGE_WLTP, 'value' => new Range(630)],
chargingProperties: new ChargingProperties( ['type' => CarPropertyType::RANGE_NEFZ, 'value' => new Range(680)],
topChargingSpeed: new Power(195) ['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')]
rangeProperties: new RangeProperties( ]
wltp: new WltpRange(new Range(630)), ]
nefz: new NefzRange(new Range(680)) ]
), ]
catalogPrice: new Price(77300, Currency::euro()), ]
image: new Image('https://www.bmw.de/content/dam/bmw/common/all-models/x-series/ix/2021/highlights/bmw-ix-sp-desktop.jpg')
),
], ],
[
// Audi e-tron GT RS 'brand' => 'Audi',
'models' => [
[ [
'model' => 'e-tron GT', 'model' => 'e-tron GT',
'revision' => new CarRevision( 'revisions' => [
name: 'RS', [
productionBegin: new Date(1, 1, 2021), 'revision' => 'RS',
drivingCharacteristics: new DrivingCharacteristics( 'properties' => [
power: new Power(475), ['type' => CarPropertyType::PRODUCTION_BEGIN, 'value' => new Date(1, 1, 2021)],
acceleration: new Acceleration(3.3), ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_POWER, 'value' => new Power(475)],
topSpeed: new Speed(250), ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_ACCELERATION, 'value' => new Acceleration(3.3)],
consumption: new Consumption(new Energy(19.6)) ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_TOP_SPEED, 'value' => new Speed(250)],
), ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_CONSUMPTION, 'value' => new Consumption(new Energy(19.6))],
battery: new BatteryProperties( ['type' => CarPropertyType::BATTERY_USABLE_CAPACITY, 'value' => new Energy(83.7)],
usableCapacity: new Energy(83.7), ['type' => CarPropertyType::BATTERY_TOTAL_CAPACITY, 'value' => new Energy(93.4)],
totalCapacity: new Energy(93.4), ['type' => CarPropertyType::BATTERY_CELL_CHEMISTRY, 'value' => CellChemistry::LithiumNickelManganeseOxide],
cellChemistry: CellChemistry::LithiumNickelManganeseOxide, ['type' => CarPropertyType::BATTERY_MODEL, 'value' => 'PPE Platform'],
model: 'PPE Platform', ['type' => CarPropertyType::BATTERY_MANUFACTURER, 'value' => 'LG Energy Solution'],
manufacturer: 'LG Energy Solution' ['type' => CarPropertyType::CHARGING_TOP_CHARGING_SPEED, 'value' => new Power(270)],
), ['type' => CarPropertyType::RANGE_WLTP, 'value' => new Range(472)],
chargingProperties: new ChargingProperties( ['type' => CarPropertyType::RANGE_NEFZ, 'value' => new Range(487)],
topChargingSpeed: new Power(270) ['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')]
rangeProperties: new RangeProperties( ]
wltp: new WltpRange(new Range(472)), ]
nefz: new NefzRange(new Range(487)) ]
), ]
catalogPrice: new Price(142900, Currency::euro()), ]
image: new Image('https://www.audi.de/content/dam/nemo/models/e-tron-gt/my-2021/1920x1080-gallery/1920x1080_AudiRS_e-tron_GT_19.jpg')
),
], ],
[
// Mercedes EQS 450+ 'brand' => 'Mercedes-Benz',
'models' => [
[ [
'model' => 'EQS', 'model' => 'EQS',
'revision' => new CarRevision( 'revisions' => [
name: '450+', [
productionBegin: new Date(1, 1, 2021), 'revision' => '450+',
drivingCharacteristics: new DrivingCharacteristics( 'properties' => [
power: new Power(245), ['type' => CarPropertyType::PRODUCTION_BEGIN, 'value' => new Date(1, 1, 2021)],
acceleration: new Acceleration(6.2), ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_POWER, 'value' => new Power(245)],
topSpeed: new Speed(210), ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_ACCELERATION, 'value' => new Acceleration(6.2)],
consumption: new Consumption(new Energy(15.7)) ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_TOP_SPEED, 'value' => new Speed(210)],
), ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_CONSUMPTION, 'value' => new Consumption(new Energy(15.7))],
battery: new BatteryProperties( ['type' => CarPropertyType::BATTERY_USABLE_CAPACITY, 'value' => new Energy(90.0)],
usableCapacity: new Energy(90.0), ['type' => CarPropertyType::BATTERY_TOTAL_CAPACITY, 'value' => new Energy(107.8)],
totalCapacity: new Energy(107.8), ['type' => CarPropertyType::BATTERY_CELL_CHEMISTRY, 'value' => CellChemistry::LithiumNickelManganeseOxide],
cellChemistry: CellChemistry::LithiumNickelManganeseOxide, ['type' => CarPropertyType::BATTERY_MODEL, 'value' => 'EVA Platform'],
model: 'EVA Platform', ['type' => CarPropertyType::BATTERY_MANUFACTURER, 'value' => 'CATL'],
manufacturer: 'CATL' ['type' => CarPropertyType::CHARGING_TOP_CHARGING_SPEED, 'value' => new Power(200)],
), ['type' => CarPropertyType::RANGE_WLTP, 'value' => new Range(756)],
chargingProperties: new ChargingProperties( ['type' => CarPropertyType::RANGE_NEFZ, 'value' => new Range(770)],
topChargingSpeed: new Power(200) ['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')]
rangeProperties: new RangeProperties( ]
wltp: new WltpRange(new Range(756)), ]
nefz: new NefzRange(new Range(770)) ]
), ]
catalogPrice: new Price(106374, Currency::euro()), ]
image: new Image('https://www.mercedes-benz.de/content/germany/de/mercedes-benz/vehicles/passenger-cars/eqs/sedan/overview/_jcr_content/root/responsivegrid/simple_stage.component.damq2.3318859008536.jpg')
),
], ],
[
// Volkswagen ID.4 Pro 'brand' => 'Volkswagen',
'models' => [
[ [
'model' => 'ID.4', 'model' => 'ID.4',
'revision' => new CarRevision( 'revisions' => [
name: 'Pro', [
productionBegin: new Date(1, 1, 2020), 'revision' => 'Pro',
drivingCharacteristics: new DrivingCharacteristics( 'properties' => [
power: new Power(150), ['type' => CarPropertyType::PRODUCTION_BEGIN, 'value' => new Date(1, 1, 2020)],
acceleration: new Acceleration(8.5), ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_POWER, 'value' => new Power(150)],
topSpeed: new Speed(160), ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_ACCELERATION, 'value' => new Acceleration(8.5)],
consumption: new Consumption(new Energy(16.3)) ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_TOP_SPEED, 'value' => new Speed(160)],
), ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_CONSUMPTION, 'value' => new Consumption(new Energy(16.3))],
battery: new BatteryProperties( ['type' => CarPropertyType::BATTERY_USABLE_CAPACITY, 'value' => new Energy(77.0)],
usableCapacity: new Energy(77.0), ['type' => CarPropertyType::BATTERY_TOTAL_CAPACITY, 'value' => new Energy(82.0)],
totalCapacity: new Energy(82.0), ['type' => CarPropertyType::BATTERY_CELL_CHEMISTRY, 'value' => CellChemistry::LithiumNickelManganeseOxide],
cellChemistry: CellChemistry::LithiumNickelManganeseOxide, ['type' => CarPropertyType::BATTERY_MODEL, 'value' => 'MEB Platform'],
model: 'MEB Platform', ['type' => CarPropertyType::BATTERY_MANUFACTURER, 'value' => 'LG Energy Solution'],
manufacturer: 'LG Energy Solution' ['type' => CarPropertyType::CHARGING_TOP_CHARGING_SPEED, 'value' => new Power(125)],
), ['type' => CarPropertyType::RANGE_WLTP, 'value' => new Range(520)],
chargingProperties: new ChargingProperties( ['type' => CarPropertyType::RANGE_NEFZ, 'value' => new Range(549)],
topChargingSpeed: new Power(125) ['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')]
rangeProperties: new RangeProperties( ]
wltp: new WltpRange(new Range(520)), ]
nefz: new NefzRange(new Range(549)) ]
), ]
catalogPrice: new Price(51515, Currency::euro()), ]
image: new Image('https://www.volkswagen.de/content/dam/vw-ngw/vw_pkw/importers/de/models/id-4/gallery/id4-gallery-exterior-01-16x9.jpg')
),
], ],
[
// Porsche Taycan Turbo S 'brand' => 'Porsche',
'models' => [
[ [
'model' => 'Taycan', 'model' => 'Taycan',
'revision' => new CarRevision( 'revisions' => [
name: 'Turbo S', [
productionBegin: new Date(1, 1, 2019), 'revision' => 'Turbo S',
drivingCharacteristics: new DrivingCharacteristics( 'properties' => [
power: new Power(560), ['type' => CarPropertyType::PRODUCTION_BEGIN, 'value' => new Date(1, 1, 2019)],
acceleration: new Acceleration(2.8), ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_POWER, 'value' => new Power(560)],
topSpeed: new Speed(260), ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_ACCELERATION, 'value' => new Acceleration(2.8)],
consumption: new Consumption(new Energy(23.7)) ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_TOP_SPEED, 'value' => new Speed(260)],
), ['type' => CarPropertyType::DRIVING_CHARACTERISTICS_CONSUMPTION, 'value' => new Consumption(new Energy(23.7))],
battery: new BatteryProperties( ['type' => CarPropertyType::BATTERY_USABLE_CAPACITY, 'value' => new Energy(83.7)],
usableCapacity: new Energy(83.7), ['type' => CarPropertyType::BATTERY_TOTAL_CAPACITY, 'value' => new Energy(93.4)],
totalCapacity: new Energy(93.4), ['type' => CarPropertyType::BATTERY_CELL_CHEMISTRY, 'value' => CellChemistry::LithiumNickelManganeseOxide],
cellChemistry: CellChemistry::LithiumNickelManganeseOxide, ['type' => CarPropertyType::BATTERY_MODEL, 'value' => 'J1 Platform'],
model: 'J1 Platform', ['type' => CarPropertyType::BATTERY_MANUFACTURER, 'value' => 'LG Energy Solution'],
manufacturer: 'LG Energy Solution' ['type' => CarPropertyType::CHARGING_TOP_CHARGING_SPEED, 'value' => new Power(270)],
), ['type' => CarPropertyType::RANGE_WLTP, 'value' => new Range(440)],
chargingProperties: new ChargingProperties( ['type' => CarPropertyType::RANGE_NEFZ, 'value' => new Range(452)],
topChargingSpeed: new Power(270) ['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')]
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') ]
),
],
]; ];
} }
} }

View File

@ -2,6 +2,9 @@
namespace App\Domain\AI; namespace App\Domain\AI;
use App\Domain\Model\AI\LargeEmbeddingVector;
use App\Domain\Model\AI\SmallEmbeddingVector;
use App\Domain\Model\Value\Vector;
use OpenAI; use OpenAI;
class AIClient class AIClient
@ -26,16 +29,23 @@ class AIClient
return $response->choices[0]->message->content ?? ''; return $response->choices[0]->message->content ?? '';
} }
/** public function embedTextLarge(string $text): LargeEmbeddingVector
* @return float[]
*/
public function embedText(string $text): array
{ {
$response = $this->client->embeddings()->create([ $response = $this->client->embeddings()->create([
'model' => 'text-embedding-3-large', 'model' => 'text-embedding-3-large',
'input' => $text, '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; namespace App\Domain\Model\AI;
use App\Domain\Model\Value\EmbeddingId;
class Embedding class Embedding
{ {
public function __construct( public function __construct(
public readonly EmbeddingId $embeddingId,
public readonly string $phrase, public readonly string $phrase,
public readonly ?LargeEmbeddingVector $largeEmbeddingVector = null, public readonly ?LargeEmbeddingVector $largeEmbeddingVector = null,
public readonly ?SmallEmbeddingVector $smallEmbeddingVector = null, public readonly ?SmallEmbeddingVector $smallEmbeddingVector = null,
@ -13,4 +16,9 @@ class Embedding
throw new \InvalidArgumentException('At least one embedding vector must be provided'); 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; namespace App\Domain\Model\Battery;
use Stringable;
enum CellChemistry: string enum CellChemistry: string
{ {
case LithiumIronPhosphate = 'LFP'; case LithiumIronPhosphate = 'LFP';

View File

@ -2,14 +2,13 @@
namespace App\Domain\Model; namespace App\Domain\Model;
use App\Domain\Model\Value\BrandId;
final readonly class Brand final readonly class Brand
{ {
public function __construct( public function __construct(
public readonly BrandId $brandId,
public readonly string $name, public readonly string $name,
public readonly ?string $logo = null, public readonly ?EmbeddingCollection $embeddings = null,
public readonly ?string $description = null,
public readonly ?int $foundedYear = null,
public readonly ?string $headquarters = null,
public readonly ?string $website = null,
) {} ) {}
} }

View File

@ -2,10 +2,14 @@
namespace App\Domain\Model; namespace App\Domain\Model;
use App\Domain\Model\Value\BrandId;
use App\Domain\Model\Value\CarModelId;
class CarModel class CarModel
{ {
public function __construct( public function __construct(
public string $name, public readonly CarModelId $carModelId,
public ?Brand $brand = null, 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; namespace App\Domain\Model;
use App\Domain\Model\Value\Consumption; use App\Domain\Model\Value\CarModelId;
use App\Domain\Model\Value\Date; use App\Domain\Model\Value\CarRevisionId;
use App\Domain\Model\Value\Price;
use App\Domain\Model\Battery\BatteryProperties;
use App\Domain\Model\Charging\ChargingProperties;
final readonly class CarRevision final readonly class CarRevision
{ {
public function __construct( public function __construct(
public string $name, public readonly CarRevisionId $carRevisionId,
public ?Date $productionBegin = null, public readonly CarModelId $carModelId,
public ?Date $productionEnd = null, public readonly string $name,
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');
}
}
}
} }

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; namespace App\Domain\Model;
final readonly class Image use Stringable;
final readonly class Image implements Stringable
{ {
public function __construct( public function __construct(
public ?string $externalPublicUrl = null, public ?string $externalPublicUrl = null,
public ?string $relativePublicUrl = 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; namespace App\Domain\Model\Value;
class Consumption class Consumption
{ {
public function __construct( 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\Brand;
use App\Domain\Model\BrandCollection; use App\Domain\Model\BrandCollection;
use App\Domain\Model\Persistence\PersistedBrand; use App\Domain\Model\CarModel;
use App\Domain\Model\Value\BrandId;
interface BrandRepository interface BrandRepository
{ {
public function findAll(): BrandCollection; 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\CarModel;
use App\Domain\Model\CarModelCollection; 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 interface CarModelRepository
{ {
public function findAll(): CarModelCollection; 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\CarRevision;
use App\Domain\Model\CarRevisionCollection; 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 interface CarRevisionRepository
{ {
public function findAll(): CarRevisionCollection; 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\Embedding;
use App\Domain\Model\AI\LargeEmbeddingVector; use App\Domain\Model\AI\LargeEmbeddingVector;
use App\Domain\Model\AI\SmallEmbeddingVector; use App\Domain\Model\AI\SmallEmbeddingVector;
use App\Domain\Model\Persistence\PersistedEmbedding; use App\Domain\Model\EmbeddingCollection;
use App\Domain\Model\Value\Vector;
interface EmbeddingRepository 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 LargeEmbeddingVector $embeddingVector
* @param int $limit * @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 SmallEmbeddingVector $vector
* @param int $limit * @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; namespace App\Domain\Search;
use App\Domain\Model\CarRevision; use App\Domain\AI\AIClient;
use App\Domain\Model\DrivingCharacteristics; use App\Domain\Model\AI\Embedding;
use App\Domain\Model\Image; use App\Domain\Repository\CarPropertyRepository;
use App\Domain\Model\Value\Currency; use App\Domain\Repository\EmbeddingRepository;
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;
class Engine 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 public function search(string $query): TileCollection
{ {
$batteryProperties = new BatteryProperties( $results = $this->embeddingRepository->searchByLargeEmbeddingVector($this->aiClient->embedTextLarge($query));
usableCapacity: new Energy(77.0),
totalCapacity: new Energy(82.0),
cellChemistry: CellChemistry::LithiumNickelManganeseOxide,
model: 'NCM811',
manufacturer: 'LG Energy Solution'
);
$chargeCurve = new ChargeCurve( $carProperties = $this->carPropertyRepository->findByEmbeddingPhraseHashes(array_map(fn (Embedding $embedding) => $embedding->phraseHash(), $results->array()));
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)
);
$chargeTimeProperties = new ChargeTimeProperties( return $this->tileBuilder->build($carProperties);
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,
])),
]),
]);
} }
} }

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; namespace App\Domain\Search;
use App\Domain\Search\Tiles\SectionTile; final class TileCollection
class TileCollection
{ {
/** /**
* @param SectionTile[] $tiles * @param object[] $tiles
*/ */
public function __construct( public function __construct(
private readonly array $tiles, private array $tiles,
) {} ) {}
/** /**
* @return SectionTile[] * @return object[]
*/ */
public function array(): array public function array(): array
{ {
return $this->tiles; 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; namespace App\Infrastructure\PostgreSQL\Repository\BrandRepository;
use App\Domain\Model\Brand; use App\Domain\Model\Brand;
use App\Domain\Model\Value\BrandId;
class ModelMapper 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( return new Brand(
name: is_string($data['name'] ?? null) ? $data['name'] : '', brandId: new BrandId($brandId),
logo: isset($data['logo']) && is_string($data['logo']) ? $data['logo'] : null, name: is_string($row['name'] ?? null) ? $row['name'] : throw new \InvalidArgumentException('Brand name is required'),
description: isset($data['description']) && is_string($data['description']) ? $data['description'] : null,
foundedYear: isset($data['founded_year']) && is_numeric($data['founded_year']) ? (int) $data['founded_year'] : null,
headquarters: isset($data['headquarters']) && is_string($data['headquarters']) ? $data['headquarters'] : null,
website: isset($data['website']) && is_string($data['website']) ? $data['website'] : null,
); );
} }
} }

View File

@ -4,7 +4,8 @@ namespace App\Infrastructure\PostgreSQL\Repository\BrandRepository;
use App\Domain\Model\Brand; use App\Domain\Model\Brand;
use App\Domain\Model\BrandCollection; 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 App\Domain\Repository\BrandRepository;
use Doctrine\DBAL\Connection; use Doctrine\DBAL\Connection;
@ -12,77 +13,67 @@ final class PostgreSQLBrandRepository implements BrandRepository
{ {
public function __construct( public function __construct(
private readonly Connection $connection, private readonly Connection $connection,
) { ) {}
}
public function findAll(): BrandCollection public function findAll(): BrandCollection
{ {
$sql = 'SELECT * FROM brands ORDER BY name ASC'; $sql = 'SELECT * FROM brands ORDER BY name ASC';
$result = $this->connection->executeQuery($sql);
$result = $this->connection->executeQuery($sql);
$brands = []; $brands = [];
$mapper = new ModelMapper(); $mapper = new ModelMapper();
foreach ($result->fetchAllAssociative() as $brand) { foreach ($result->fetchAllAssociative() as $row) {
$brands[] = $mapper->map($brand); $brands[] = $mapper->map($row);
} }
return new BrandCollection($brands); 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 $sql = 'SELECT brands.* FROM brands
$brandId = uniqid('brand_', true); INNER JOIN car_models ON brands.id = car_models.brand_id
WHERE car_models.id = ?';
$sql = <<<'SQL' $result = $this->connection->executeQuery($sql, [$carModel->carModelId->value]);
INSERT INTO brands (id, name, content) $row = $result->fetchAssociative();
VALUES (?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
content = EXCLUDED.content
SQL;
$content = json_encode([ if ($row === false) {
'logo' => $brand->logo, return null;
'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);
} }
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' $result = $this->connection->executeQuery($sql, [$brandId->value]);
UPDATE brands SET $row = $result->fetchAssociative();
name = ?,
content = ?
WHERE id = ?
SQL;
$content = json_encode([ if ($row === false) {
'logo' => $brand->logo, return null;
'description' => $brand->description, }
'founded_year' => $brand->foundedYear,
'headquarters' => $brand->headquarters, $mapper = new ModelMapper();
'website' => $brand->website, 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, [ $this->connection->executeStatement($sql, [
$brand->name, $brand->brandId->value,
$content, $brand->name
$persistedBrand->id,
]); ]);
} }
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\CarModel;
use App\Domain\Model\Brand; use App\Domain\Model\Brand;
use App\Domain\Model\Value\BrandId;
use App\Domain\Model\Value\CarModelId;
class ModelMapper 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'] : '{}'; $carModelId = is_string($row['id'] ?? null) ? $row['id'] : throw new \InvalidArgumentException('CarModel ID is required');
$content = json_decode($contentString, true); $brandId = is_string($row['brand_id'] ?? null) ? $row['brand_id'] : throw new \InvalidArgumentException('Brand ID is required');
$brand = null;
if (is_array($content) && !empty($content['brand']) && is_string($content['brand'])) {
$brand = new Brand(name: $content['brand']);
}
return new CarModel( return new CarModel(
name: is_string($data['name'] ?? null) ? $data['name'] : '', carModelId: new CarModelId($carModelId),
brand: $brand, 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\CarModel;
use App\Domain\Model\CarModelCollection; 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 App\Domain\Repository\CarModelRepository;
use Doctrine\DBAL\Connection; use Doctrine\DBAL\Connection;
@ -12,108 +14,89 @@ final class PostgreSQLCarModelRepository implements CarModelRepository
{ {
public function __construct( public function __construct(
private readonly Connection $connection, private readonly Connection $connection,
) { ) {}
}
public function findAll(): CarModelCollection public function findAll(): CarModelCollection
{ {
$sql = 'SELECT * FROM car_models ORDER BY name ASC'; $sql = 'SELECT * FROM car_models ORDER BY name ASC';
$result = $this->connection->executeQuery($sql);
$result = $this->connection->executeQuery($sql);
$carModels = []; $carModels = [];
$mapper = new ModelMapper(); $mapper = new ModelMapper();
foreach ($result->fetchAllAssociative() as $carModel) { foreach ($result->fetchAllAssociative() as $row) {
$carModels[] = $mapper->map($carModel); $carModels[] = $mapper->map($row);
} }
return new CarModelCollection($carModels); return new CarModelCollection($carModels);
} }
public function findById(string $id): ?PersistedCarModel public function findById(CarModelId $carModelId): ?CarModel
{ {
$sql = 'SELECT * FROM car_models WHERE id = ?'; $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; return null;
} }
$mapper = new ModelMapper(); $mapper = new ModelMapper();
$carModel = $mapper->map($data); return $mapper->map($row);
return new PersistedCarModel(is_string($data['id'] ?? null) ? $data['id'] : '', $carModel);
} }
public function findByBrandId(string $brandId): CarModelCollection public function findByBrandId(BrandId $brandId): CarModelCollection
{ {
$sql = 'SELECT * FROM car_models WHERE brand_id = ? ORDER BY name ASC'; $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 = []; $carModels = [];
$mapper = new ModelMapper(); $mapper = new ModelMapper();
foreach ($result->fetchAllAssociative() as $carModel) { foreach ($result->fetchAllAssociative() as $row) {
$carModels[] = $mapper->map($carModel); $carModels[] = $mapper->map($row);
} }
return new CarModelCollection($carModels); 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 $sql = 'SELECT car_models.* FROM car_models
$carModelId = uniqid('carmodel_', true); INNER JOIN car_revisions ON car_models.id = car_revisions.car_model_id
WHERE car_revisions.id = ?';
$sql = <<<'SQL' $result = $this->connection->executeQuery($sql, [$carRevision->carRevisionId->value]);
INSERT INTO car_models (id, brand_id, name, content) $row = $result->fetchAssociative();
VALUES (?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
brand_id = EXCLUDED.brand_id,
name = EXCLUDED.name,
content = EXCLUDED.content
SQL;
$content = json_encode([ if ($row === false) {
'brand' => $carModel->brand->name ?? null, return null;
]);
$this->connection->executeStatement($sql, [
$carModelId,
$brandId,
$carModel->name,
$content,
]);
return new PersistedCarModel($carModelId, $carModel);
} }
public function update(PersistedCarModel $persistedCarModel): void $mapper = new ModelMapper();
return $mapper->map($row);
}
public function save(CarModel $carModel): void
{ {
$carModel = $persistedCarModel->carModel; $sql = 'INSERT INTO car_models (id, brand_id, name) VALUES (?, ?, ?) ON CONFLICT (id) DO UPDATE SET brand_id = EXCLUDED.brand_id, name = EXCLUDED.name';
$sql = <<<'SQL'
UPDATE car_models SET
name = ?,
content = ?
WHERE id = ?
SQL;
$content = json_encode([
'brand' => $carModel->brand->name ?? null,
]);
$this->connection->executeStatement($sql, [ $this->connection->executeStatement($sql, [
$carModel->carModelId->value,
$carModel->brandId->value,
$carModel->name, $carModel->name,
$content,
$persistedCarModel->id,
]); ]);
} }
public function delete(PersistedCarModel $persistedCarModel): void public function delete(CarModel $carModel): void
{ {
$sql = 'DELETE FROM car_models WHERE id = ?'; $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; namespace App\Infrastructure\PostgreSQL\Repository\CarRevisionRepository;
use App\Domain\Model\CarRevision; use App\Domain\Model\CarRevision;
use App\Domain\Model\DrivingCharacteristics; use App\Domain\Model\Value\CarModelId;
use App\Domain\Model\Image; use App\Domain\Model\Value\CarRevisionId;
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;
class ModelMapper 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'] : '{}'; $carRevisionId = is_string($row['id'] ?? null) ? $row['id'] : throw new \InvalidArgumentException('CarRevision ID is required');
$content = json_decode($contentString, true); $carModelId = is_string($row['car_model_id'] ?? null) ? $row['car_model_id'] : throw new \InvalidArgumentException('CarModel ID is required');
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,
);
}
}
return new CarRevision( return new CarRevision(
name: is_string($data['name'] ?? null) ? $data['name'] : '', carRevisionId: new CarRevisionId($carRevisionId),
productionBegin: $productionBegin, carModelId: new CarModelId($carModelId),
productionEnd: $productionEnd, name: is_string($row['name'] ?? null) ? $row['name'] : throw new \InvalidArgumentException('CarRevision name is required'),
drivingCharacteristics: $drivingCharacteristics,
battery: $battery,
chargingProperties: $chargingProperties,
rangeProperties: $rangeProperties,
catalogPrice: $catalogPrice,
carModel: null, // CarModel would need to be loaded separately if needed
image: $image,
); );
} }
} }

View File

@ -4,7 +4,8 @@ namespace App\Infrastructure\PostgreSQL\Repository\CarRevisionRepository;
use App\Domain\Model\CarRevision; use App\Domain\Model\CarRevision;
use App\Domain\Model\CarRevisionCollection; 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 App\Domain\Repository\CarRevisionRepository;
use Doctrine\DBAL\Connection; use Doctrine\DBAL\Connection;
@ -12,156 +13,74 @@ final class PostgreSQLCarRevisionRepository implements CarRevisionRepository
{ {
public function __construct( public function __construct(
private readonly Connection $connection, private readonly Connection $connection,
) { ) {}
}
public function findAll(): CarRevisionCollection public function findAll(): CarRevisionCollection
{ {
$sql = 'SELECT * FROM car_revisions ORDER BY name ASC'; $sql = 'SELECT * FROM car_revisions ORDER BY name ASC';
$result = $this->connection->executeQuery($sql);
$result = $this->connection->executeQuery($sql);
$carRevisions = []; $carRevisions = [];
$mapper = new ModelMapper(); $mapper = new ModelMapper();
foreach ($result->fetchAllAssociative() as $carRevision) { foreach ($result->fetchAllAssociative() as $row) {
$carRevisions[] = $mapper->map($carRevision); $carRevisions[] = $mapper->map($row);
} }
return new CarRevisionCollection($carRevisions); return new CarRevisionCollection($carRevisions);
} }
public function findById(string $id): ?PersistedCarRevision public function findById(CarRevisionId $carRevisionId): ?CarRevision
{ {
$sql = 'SELECT * FROM car_revisions WHERE id = ?'; $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; return null;
} }
$mapper = new ModelMapper(); $mapper = new ModelMapper();
$carRevision = $mapper->map($data); return $mapper->map($row);
return new PersistedCarRevision(is_string($data['id'] ?? null) ? $data['id'] : '', $carRevision);
} }
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'; $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 = []; $carRevisions = [];
$mapper = new ModelMapper(); $mapper = new ModelMapper();
foreach ($result->fetchAllAssociative() as $carRevision) { foreach ($result->fetchAllAssociative() as $row) {
$carRevisions[] = $mapper->map($carRevision); $carRevisions[] = $mapper->map($row);
} }
return new CarRevisionCollection($carRevisions); 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 $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';
$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,
],
]);
$this->connection->executeStatement($sql, [ $this->connection->executeStatement($sql, [
$carRevisionId, $carRevision->carRevisionId->value,
$carModelId, $carRevision->carModelId->value,
$carRevision->name, $carRevision->name
$content,
]); ]);
return new PersistedCarRevision($carRevisionId, $carRevision); return;
} }
public function update(PersistedCarRevision $persistedCarRevision): void public function delete(CarRevision $carRevision): 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
{ {
$sql = 'DELETE FROM car_revisions WHERE id = ?'; $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\Embedding;
use App\Domain\Model\AI\LargeEmbeddingVector; use App\Domain\Model\AI\LargeEmbeddingVector;
use App\Domain\Model\AI\SmallEmbeddingVector; 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( public function __construct(
private readonly Connection $connection, private readonly Connection $connection,
) { ) {}
}
public function create(Embedding $embedding): PersistedEmbedding public function save(Embedding $embedding): void
{ {
$hash = md5($embedding->phrase); $this->connection->executeStatement(
$this->connection->executeStatement(<<<SQL 'INSERT INTO embeddings (id, phrase_hash, phrase, large_embedding_vector, small_embedding_vector) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING',
INSERT INTO embeddings (phrase_hash, phrase, large_embedding_vector, small_embedding_vector) [
VALUES (:phrase_hash, :phrase, :large_embedding_vector, :small_embedding_vector) $embedding->embeddingId->value,
SQL, [ $embedding->phraseHash(),
'phrase_hash' => $hash, $embedding->phrase,
'phrase' => $embedding->phrase, $embedding->largeEmbeddingVector !== null
'large_embedding_vector' => $embedding->largeEmbeddingVector !== null ? '[' . implode(',', $embedding->largeEmbeddingVector->vector->values) . ']' : null, ? '[' . implode(',', $embedding->largeEmbeddingVector->vector->values) . ']'
'small_embedding_vector' => $embedding->smallEmbeddingVector !== null ? '[' . implode(',', $embedding->smallEmbeddingVector->vector->values) . ']' : null, : null,
]); $embedding->smallEmbeddingVector !== null
? '[' . implode(',', $embedding->smallEmbeddingVector->vector->values) . ']'
return new PersistedEmbedding( : null,
$hash, ]
$embedding,
); );
} }
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 $result = $this->connection->executeQuery(
SELECT phrase_hash, phrase, large_embedding_vector, small_embedding_vector, created_at '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 FROM embeddings
WHERE large_embedding_vector IS NOT NULL WHERE large_embedding_vector IS NOT NULL
ORDER BY large_embedding_vector <=> :vector ORDER BY large_embedding_vector <=> ?
LIMIT :limit LIMIT ?',
SQL, [ [
'vector' => '[' . implode(',', $embeddingVector->vector->values) . ']', '[' . implode(',', $embeddingVector->vector->values) . ']',
'limit' => $limit, $limit,
]); ]
);
$embeddings = []; $embeddings = [];
foreach ($result->fetchAllAssociative() as $row) { foreach ($result->fetchAllAssociative() as $row) {
$embeddings[] = new PersistedEmbedding( $embeddings[] = $this->mapRowToEmbedding($row);
is_string($row['phrase_hash'] ?? null) ? $row['phrase_hash'] : '',
new Embedding(is_string($row['phrase'] ?? null) ? $row['phrase'] : '', $embeddingVector)
);
} }
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 $result = $this->connection->executeQuery(
SELECT phrase_hash, phrase, large_embedding_vector, small_embedding_vector, created_at 'SELECT *
FROM embeddings FROM embeddings
WHERE small_embedding_vector IS NOT NULL WHERE small_embedding_vector IS NOT NULL
ORDER BY small_embedding_vector <=> :vector ORDER BY small_embedding_vector <=> ?
LIMIT :limit LIMIT ?',
SQL, [ [
'vector' => '[' . implode(',', $smallEmbeddingVector->vector->values) . ']', '[' . implode(',', $smallEmbeddingVector->vector->values) . ']',
'limit' => $limit, $limit,
]); ]
);
$embeddings = []; $embeddings = [];
foreach ($result->fetchAllAssociative() as $row) { foreach ($result->fetchAllAssociative() as $row) {
$embeddings[] = new PersistedEmbedding( $embeddings[] = $this->mapRowToEmbedding($row);
is_string($row['phrase_hash'] ?? null) ? $row['phrase_hash'] : '',
new Embedding(is_string($row['phrase'] ?? null) ? $row['phrase'] : '', null, $smallEmbeddingVector)
);
} }
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> <h2 class="section-title"><i class="fas fa-industry"></i> Popular Electric Vehicle Brands</h2>
<div class="brands-grid"> <div class="brands-grid">
{% for brand in brands %} {% 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> <div class="brand-name"><i class="fas fa-car"></i> {{ brand.name }}</div>
{% if brand.description %} {% if brand.description %}
<div class="brand-description">{{ brand.description }}</div> <div class="brand-description">{{ brand.description }}</div>