first attempt to use an ai based search

This commit is contained in:
Tim Lappe 2025-06-09 15:42:22 +02:00
parent acd669e180
commit 3f78e2e9f1
100 changed files with 2406 additions and 691 deletions

View File

@ -30,7 +30,9 @@
"phpstan/phpstan-symfony": "^2.0", "phpstan/phpstan-symfony": "^2.0",
"symfony/debug-bundle": "7.2.*", "symfony/debug-bundle": "7.2.*",
"symfony/maker-bundle": "^1.0", "symfony/maker-bundle": "^1.0",
"symfony/var-dumper": "7.2.*" "symfony/stopwatch": "^7.3",
"symfony/var-dumper": "7.2.*",
"symfony/web-profiler-bundle": "^7.3"
}, },
"config": { "config": {
"allow-plugins": { "allow-plugins": {

87
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "aff00a8d8bd55186f046d02ac6fd7c4d", "content-hash": "e4da23c3811aae55314b0e018026605a",
"packages": [ "packages": [
{ {
"name": "composer/semver", "name": "composer/semver",
@ -4572,6 +4572,91 @@
], ],
"time": "2025-05-15T09:04:05+00:00" "time": "2025-05-15T09:04:05+00:00"
}, },
{
"name": "symfony/web-profiler-bundle",
"version": "v7.3.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/web-profiler-bundle.git",
"reference": "a22b7e4a744820a56f1bafa830f2c72a2ba0913c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/a22b7e4a744820a56f1bafa830f2c72a2ba0913c",
"reference": "a22b7e4a744820a56f1bafa830f2c72a2ba0913c",
"shasum": ""
},
"require": {
"composer-runtime-api": ">=2.1",
"php": ">=8.2",
"symfony/config": "^7.3",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/framework-bundle": "^6.4|^7.0",
"symfony/http-kernel": "^6.4|^7.0",
"symfony/routing": "^6.4|^7.0",
"symfony/twig-bundle": "^6.4|^7.0",
"twig/twig": "^3.12"
},
"conflict": {
"symfony/form": "<6.4",
"symfony/mailer": "<6.4",
"symfony/messenger": "<6.4",
"symfony/serializer": "<7.2",
"symfony/workflow": "<7.3"
},
"require-dev": {
"symfony/browser-kit": "^6.4|^7.0",
"symfony/console": "^6.4|^7.0",
"symfony/css-selector": "^6.4|^7.0",
"symfony/stopwatch": "^6.4|^7.0"
},
"type": "symfony-bundle",
"autoload": {
"psr-4": {
"Symfony\\Bundle\\WebProfilerBundle\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides a development tool that gives detailed information about the execution of any request",
"homepage": "https://symfony.com",
"keywords": [
"dev"
],
"support": {
"source": "https://github.com/symfony/web-profiler-bundle/tree/v7.3.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-05-02T05:30:54+00:00"
},
{ {
"name": "symfony/yaml", "name": "symfony/yaml",
"version": "v7.2.6", "version": "v7.2.6",

View File

@ -7,4 +7,5 @@ return [
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
]; ];

View File

@ -0,0 +1,11 @@
when@dev:
web_profiler:
toolbar: true
framework:
profiler:
collect_serializer_data: true
when@test:
framework:
profiler: { collect: false }

View File

@ -0,0 +1,8 @@
when@dev:
web_profiler_wdt:
resource: '@WebProfilerBundle/Resources/config/routing/wdt.php'
prefix: /_wdt
web_profiler_profiler:
resource: '@WebProfilerBundle/Resources/config/routing/profiler.php'
prefix: /_profiler

View File

@ -14,4 +14,12 @@ services:
App\Domain\AI\AIClient: App\Domain\AI\AIClient:
arguments: arguments:
$apiKey: '%env(OPENAI_API_KEY)%' $apiKey: '%env(OPENAI_API_KEY)%'
App\Application\DataCollector\AiChatDataCollector:
arguments:
$aiClient: '@App\Domain\AI\AIClient'
tags:
- { name: data_collector, id: 'ai_chat', template: 'profiler/ai-chat.html.twig' }
App\Domain\Search\Engine: '@App\Domain\Search\AiTileEngine'

View File

@ -0,0 +1,29 @@
<?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 Version20250609005825 extends AbstractMigration
{
public function getDescription(): string
{
return 'Remove car property type column';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE car_properties DROP COLUMN type');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE car_properties ADD COLUMN type VARCHAR(255) NOT NULL');
}
}

View File

@ -3,9 +3,9 @@
namespace App\Application\Commands; namespace App\Application\Commands;
use App\Domain\ContentManagement\CarPropertyEmbedder; use App\Domain\ContentManagement\CarPropertyEmbedder;
use App\Domain\Model\Brand; use App\Domain\Model\Cars\Brand;
use App\Domain\Model\CarModel; use App\Domain\Model\Cars\CarModel;
use App\Domain\Model\CarRevision; use App\Domain\Model\Cars\CarRevision;
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;
@ -14,12 +14,23 @@ use App\Domain\Model\Id\BrandId;
use App\Domain\Model\Id\CarModelId; use App\Domain\Model\Id\CarModelId;
use App\Domain\Model\Id\CarRevisionId; use App\Domain\Model\Id\CarRevisionId;
use App\Domain\Model\Id\CarPropertyId; use App\Domain\Model\Id\CarPropertyId;
use App\Domain\Model\Battery\CellChemistry; use App\Domain\Model\Cars\CarProperty;
use App\Domain\Model\CarProperty; use App\Domain\Model\Cars\CarPropertyValues\V1\CarPropertyValue;
use App\Domain\Model\CarPropertyType; use App\Domain\Model\Cars\CarPropertyValues\V1\Production;
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\Cars\CarPropertyValues\V1\Acceleration;
use App\Domain\Model\Cars\CarPropertyValues\V1\AverageConsumption;
use App\Domain\Model\Cars\CarPropertyValues\V1\Battery\BatteryCapacity;
use App\Domain\Model\Cars\CarPropertyValues\V1\Battery\BatteryType;
use App\Domain\Model\Cars\CarPropertyValues\V1\Battery\CellChemistry;
use App\Domain\Model\Cars\CarPropertyValues\V1\CatalogPrice;
use App\Domain\Model\Cars\CarPropertyValues\V1\Charging\ChargingSpeed;
use App\Domain\Model\Cars\CarPropertyValues\V1\MotorPower;
use App\Domain\Model\Cars\CarPropertyValues\V1\RangeSpecification;
use App\Domain\Model\Cars\CarPropertyValues\V1\TopSpeed;
use App\Domain\Model\Range\NefzRange;
use App\Domain\Model\Range\WltpRange;
use App\Domain\Model\Value\Speed; use App\Domain\Model\Value\Speed;
use App\Domain\Model\Value\Consumption; use App\Domain\Model\Value\Consumption;
use App\Domain\Model\Value\Range; use App\Domain\Model\Value\Range;
@ -145,15 +156,14 @@ class LoadFixtures extends Command
$this->carRevisionRepository->save($carRevision); $this->carRevisionRepository->save($carRevision);
foreach ($revisionFixture['properties'] as $propertyData) { foreach ($revisionFixture['properties'] as $propertyValue) {
$property = new CarProperty( $property = new CarProperty(
CarPropertyId::generate(), CarPropertyId::generate(),
$carRevision->carRevisionId, $carRevision->carRevisionId,
$propertyData['type'], $propertyValue
$propertyData['value']
); );
$this->carPropertyEmbedder->createPersistedEmbedding($property, $carRevision, $brand); $this->carPropertyEmbedder->createPersistedEmbedding($property, $carRevision, $carModel, $brand);
$this->carPropertyRepository->save($property); $this->carPropertyRepository->save($property);
} }
@ -170,11 +180,183 @@ class LoadFixtures extends Command
} }
/** /**
* @return array<array{brand: string, models: array<array{model: string, revisions: array<array{revision: string, image: Image, properties: array<array{type: CarPropertyType, value: mixed}>}>}>}> * @return array<array{brand: string, models: array<array{model: string, revisions: array<array{revision: string, image: Image|null, properties: array<CarPropertyValue>}>}>}>
*/ */
private function getFixtures(): array private function getFixtures(): array
{ {
return [ return [
[
'brand' => 'Skoda',
'models' => [
[
'model' => 'Elroq',
'revisions' => [
[
'revision' => '85',
'image' => null,
'properties' => [
new Production(productionBegin: new Date(10, 1, 2024)),
new MotorPower(new Power(210)), // Estimated from 0-100 time and specs
new Acceleration(6.6),
new TopSpeed(new Speed(180)), // Estimated typical for this class
new AverageConsumption(new Consumption(new Energy(17.1))),
new BatteryCapacity(new Energy(77.0), new Energy(82.0)), // Usable/gross capacity
new BatteryType(CellChemistry::LithiumIronPhosphate, 'MEB Platform', 'CATL'),
new ChargingSpeed(new Power(120), new Power(120)),
new RangeSpecification(new NefzRange(new Range(493)), new WltpRange(new Range(450))),
new CatalogPrice(new Price(43900, Currency::euro())),
]
]
]
]
]
],
[
'brand' => 'Mercedes-Benz',
'models' => [
[
'model' => 'CLA',
'revisions' => [
[
'revision' => '250+',
'image' => null,
'properties' => [
new Production(productionBegin: new Date(5, 1, 2025)),
new MotorPower(new Power(200)), // Estimated from acceleration
new Acceleration(6.7),
new TopSpeed(new Speed(210)), // Estimated
new AverageConsumption(new Consumption(new Energy(15.0))),
new BatteryCapacity(new Energy(85.0), new Energy(90.0)),
new BatteryType(CellChemistry::LithiumNickelManganeseOxide, 'MMA Platform', 'CATL'),
new ChargingSpeed(new Power(170), new Power(170)),
new RangeSpecification(new NefzRange(new Range(700)), new WltpRange(new Range(565))),
new CatalogPrice(new Price(55859, Currency::euro())),
]
]
]
],
[
'model' => 'EQS',
'revisions' => [
[
'revision' => '450+',
'image' => new Image(externalPublicUrl: 'https://ev-database.org/img/auto/Mercedes_EQS_2024/Mercedes_EQS_2024-01@2x.jpg'),
'properties' => [
new Production(productionBegin: new Date(1, 1, 2021)),
new MotorPower(new Power(245)),
new Acceleration(6.2),
new TopSpeed(new Speed(210)),
new AverageConsumption(new Consumption(new Energy(15.7))),
new BatteryCapacity(new Energy(90.0), new Energy(107.8)),
new BatteryType(CellChemistry::LithiumNickelManganeseOxide, 'EVA Platform', 'CATL'),
new ChargingSpeed(new Power(200), new Power(200)),
new RangeSpecification(new NefzRange(new Range(770)), new WltpRange(new Range(756))),
new CatalogPrice(new Price(106374, Currency::euro())),
]
]
]
]
]
],
[
'brand' => 'Kia',
'models' => [
[
'model' => 'EV3',
'revisions' => [
[
'revision' => '81.4 kWh',
'image' => null,
'properties' => [
new Production(productionBegin: new Date(8, 1, 2024)),
new MotorPower(new Power(150)), // Estimated from acceleration
new Acceleration(7.7),
new TopSpeed(new Speed(170)), // Estimated
new AverageConsumption(new Consumption(new Energy(17.1))),
new BatteryCapacity(new Energy(78.0), new Energy(81.4)),
new BatteryType(CellChemistry::LithiumIronPhosphate, 'E-GMP Platform', 'CATL'),
new ChargingSpeed(new Power(105), new Power(105)),
new RangeSpecification(new NefzRange(new Range(472)), new WltpRange(new Range(455))),
new CatalogPrice(new Price(41390, Currency::euro())),
]
]
]
]
]
],
[
'brand' => 'Smart',
'models' => [
[
'model' => '#5',
'revisions' => [
[
'revision' => 'Premium',
'image' => null,
'properties' => [
new Production(productionBegin: new Date(5, 1, 2025)),
new MotorPower(new Power(250)), // Estimated from acceleration
new Acceleration(6.5),
new TopSpeed(new Speed(180)), // Estimated
new AverageConsumption(new Consumption(new Energy(20.2))),
new BatteryCapacity(new Energy(94.0), new Energy(100.0)),
new BatteryType(CellChemistry::LithiumNickelManganeseOxide, 'SEA Platform', 'CATL'),
new ChargingSpeed(new Power(230), new Power(230)),
new RangeSpecification(new NefzRange(new Range(579)), new WltpRange(new Range(465))),
new CatalogPrice(new Price(55400, Currency::euro())),
]
]
]
]
]
],
[
'brand' => 'Volkswagen',
'models' => [
[
'model' => 'ID.7',
'revisions' => [
[
'revision' => 'Pro',
'image' => null,
'properties' => [
new Production(productionBegin: new Date(8, 1, 2023)),
new MotorPower(new Power(210)), // Estimated from acceleration
new Acceleration(6.5),
new TopSpeed(new Speed(180)), // Estimated
new AverageConsumption(new Consumption(new Energy(16.2))),
new BatteryCapacity(new Energy(77.0), new Energy(82.0)),
new BatteryType(CellChemistry::LithiumNickelManganeseOxide, 'MEB Platform', 'LG Energy Solution'),
new ChargingSpeed(new Power(170), new Power(170)),
new RangeSpecification(new NefzRange(new Range(621)), new WltpRange(new Range(475))),
new CatalogPrice(new Price(53995, Currency::euro())),
]
]
]
],
[
'model' => 'ID.4',
'revisions' => [
[
'revision' => 'Pro',
'image' => null,
'properties' => [
new Production(productionBegin: new Date(1, 1, 2020)),
new MotorPower(new Power(150)),
new Acceleration(8.5),
new TopSpeed(new Speed(160)),
new AverageConsumption(new Consumption(new Energy(16.3))),
new BatteryCapacity(new Energy(77.0), new Energy(82.0)),
new BatteryType(CellChemistry::LithiumNickelManganeseOxide, 'MEB Platform', 'LG Energy Solution'),
new ChargingSpeed(new Power(125), new Power(125)),
new RangeSpecification(new NefzRange(new Range(549)), new WltpRange(new Range(520))),
new CatalogPrice(new Price(51515, Currency::euro())),
]
]
]
]
]
],
[ [
'brand' => 'Tesla', 'brand' => 'Tesla',
'models' => [ 'models' => [
@ -185,20 +367,16 @@ class LoadFixtures extends Command
'revision' => 'Plaid', 'revision' => 'Plaid',
'image' => new Image(externalPublicUrl: 'https://digitalassets.tesla.com/tesla-contents/image/upload/f_auto,q_auto/Model-S-Main-Hero-Desktop-LHD.jpg'), 'image' => new Image(externalPublicUrl: 'https://digitalassets.tesla.com/tesla-contents/image/upload/f_auto,q_auto/Model-S-Main-Hero-Desktop-LHD.jpg'),
'properties' => [ 'properties' => [
['type' => CarPropertyType::PRODUCTION_BEGIN, 'value' => new Date(1, 1, 2021)], new Production(productionBegin: new Date(1, 1, 2021)),
['type' => CarPropertyType::DRIVING_CHARACTERISTICS_POWER, 'value' => new Power(750)], new MotorPower(new Power(750)),
['type' => CarPropertyType::DRIVING_CHARACTERISTICS_ACCELERATION, 'value' => new Acceleration(2.1)], new Acceleration(2.1),
['type' => CarPropertyType::DRIVING_CHARACTERISTICS_TOP_SPEED, 'value' => new Speed(322)], new TopSpeed(new Speed(322)),
['type' => CarPropertyType::DRIVING_CHARACTERISTICS_CONSUMPTION, 'value' => new Consumption(new Energy(19.3))], new AverageConsumption(new Consumption(new Energy(19.3))),
['type' => CarPropertyType::BATTERY_USABLE_CAPACITY, 'value' => new Energy(95.0)], new BatteryCapacity(new Energy(95.0), new Energy(100.0)),
['type' => CarPropertyType::BATTERY_TOTAL_CAPACITY, 'value' => new Energy(100.0)], new BatteryType(CellChemistry::LithiumNickelManganeseOxide, '4680', 'Tesla'),
['type' => CarPropertyType::BATTERY_CELL_CHEMISTRY, 'value' => CellChemistry::LithiumNickelManganeseOxide], new ChargingSpeed(new Power(250), new Power(250)),
['type' => CarPropertyType::BATTERY_MODEL, 'value' => '4680'], new RangeSpecification(new NefzRange(new Range(652)), new WltpRange(new Range(628))),
['type' => CarPropertyType::BATTERY_MANUFACTURER, 'value' => 'Tesla'], new CatalogPrice(new Price(129990, Currency::euro())),
['type' => CarPropertyType::CHARGING_TOP_CHARGING_SPEED, 'value' => new Power(250)],
['type' => CarPropertyType::RANGE_WLTP, 'value' => new Range(628)],
['type' => CarPropertyType::RANGE_NEFZ, 'value' => new Range(652)],
['type' => CarPropertyType::CATALOG_PRICE, 'value' => new Price(129990, Currency::euro())],
] ]
] ]
] ]
@ -210,20 +388,16 @@ class LoadFixtures extends Command
'revision' => 'Long Range', 'revision' => 'Long Range',
'image' => new Image(externalPublicUrl: 'https://digitalassets.tesla.com/tesla-contents/image/upload/f_auto,q_auto/Model-3-Main-Hero-Desktop-LHD.jpg'), 'image' => new Image(externalPublicUrl: 'https://digitalassets.tesla.com/tesla-contents/image/upload/f_auto,q_auto/Model-3-Main-Hero-Desktop-LHD.jpg'),
'properties' => [ 'properties' => [
['type' => CarPropertyType::PRODUCTION_BEGIN, 'value' => new Date(1, 1, 2020)], new Production(productionBegin: new Date(1, 1, 2020)),
['type' => CarPropertyType::DRIVING_CHARACTERISTICS_POWER, 'value' => new Power(366)], new MotorPower(new Power(366)),
['type' => CarPropertyType::DRIVING_CHARACTERISTICS_ACCELERATION, 'value' => new Acceleration(4.4)], new Acceleration(4.4),
['type' => CarPropertyType::DRIVING_CHARACTERISTICS_TOP_SPEED, 'value' => new Speed(233)], new TopSpeed(new Speed(233)),
['type' => CarPropertyType::DRIVING_CHARACTERISTICS_CONSUMPTION, 'value' => new Consumption(new Energy(14.9))], new AverageConsumption(new Consumption(new Energy(14.9))),
['type' => CarPropertyType::BATTERY_USABLE_CAPACITY, 'value' => new Energy(75.0)], new BatteryCapacity(new Energy(75.0), new Energy(82.0)),
['type' => CarPropertyType::BATTERY_TOTAL_CAPACITY, 'value' => new Energy(82.0)], new BatteryType(CellChemistry::LithiumNickelManganeseOxide, '2170', 'Tesla'),
['type' => CarPropertyType::BATTERY_CELL_CHEMISTRY, 'value' => CellChemistry::LithiumNickelManganeseOxide], new ChargingSpeed(new Power(250), new Power(250)),
['type' => CarPropertyType::BATTERY_MODEL, 'value' => '2170'], new RangeSpecification(new NefzRange(new Range(614)), new WltpRange(new Range(602))),
['type' => CarPropertyType::BATTERY_MANUFACTURER, 'value' => 'Tesla'], new CatalogPrice(new Price(129990, Currency::euro())),
['type' => CarPropertyType::CHARGING_TOP_CHARGING_SPEED, 'value' => new Power(250)],
['type' => CarPropertyType::RANGE_WLTP, 'value' => new Range(602)],
['type' => CarPropertyType::RANGE_NEFZ, 'value' => new Range(614)],
['type' => CarPropertyType::CATALOG_PRICE, 'value' => new Price(49990, Currency::euro())],
] ]
] ]
] ]
@ -238,22 +412,18 @@ class LoadFixtures extends Command
'revisions' => [ 'revisions' => [
[ [
'revision' => 'xDrive50', 'revision' => 'xDrive50',
'image' => new Image(externalPublicUrl: 'https://www.bmw.de/content/dam/bmw/common/all-models/x-series/ix/2021/highlights/bmw-ix-sp-desktop.jpg'), 'image' => new Image(externalPublicUrl: 'https://cdn.motor1.com/images/mgl/N7Lgn/s1/bmw-ix-xdrive50-2021.jpg'),
'properties' => [ 'properties' => [
['type' => CarPropertyType::PRODUCTION_BEGIN, 'value' => new Date(1, 1, 2021)], new Production(productionBegin: new Date(1, 1, 2021)),
['type' => CarPropertyType::DRIVING_CHARACTERISTICS_POWER, 'value' => new Power(385)], new MotorPower(new Power(385)),
['type' => CarPropertyType::DRIVING_CHARACTERISTICS_ACCELERATION, 'value' => new Acceleration(4.6)], new Acceleration(4.6),
['type' => CarPropertyType::DRIVING_CHARACTERISTICS_TOP_SPEED, 'value' => new Speed(200)], new TopSpeed(new Speed(200)),
['type' => CarPropertyType::DRIVING_CHARACTERISTICS_CONSUMPTION, 'value' => new Consumption(new Energy(19.8))], new AverageConsumption(new Consumption(new Energy(19.8))),
['type' => CarPropertyType::BATTERY_USABLE_CAPACITY, 'value' => new Energy(71.2)], new BatteryCapacity(new Energy(71.2), new Energy(76.6)),
['type' => CarPropertyType::BATTERY_TOTAL_CAPACITY, 'value' => new Energy(76.6)], new BatteryType(CellChemistry::LithiumNickelManganeseOxide, 'BMW Gen5', 'CATL'),
['type' => CarPropertyType::BATTERY_CELL_CHEMISTRY, 'value' => CellChemistry::LithiumNickelManganeseOxide], new ChargingSpeed(new Power(195), new Power(195)),
['type' => CarPropertyType::BATTERY_MODEL, 'value' => 'BMW Gen5'], new RangeSpecification(new NefzRange(new Range(680)), new WltpRange(new Range(630))),
['type' => CarPropertyType::BATTERY_MANUFACTURER, 'value' => 'CATL'], new CatalogPrice(new Price(77300, Currency::euro())),
['type' => CarPropertyType::CHARGING_TOP_CHARGING_SPEED, 'value' => new Power(195)],
['type' => CarPropertyType::RANGE_WLTP, 'value' => new Range(630)],
['type' => CarPropertyType::RANGE_NEFZ, 'value' => new Range(680)],
['type' => CarPropertyType::CATALOG_PRICE, 'value' => new Price(77300, Currency::euro())],
] ]
] ]
] ]
@ -268,82 +438,18 @@ class LoadFixtures extends Command
'revisions' => [ 'revisions' => [
[ [
'revision' => 'RS', 'revision' => 'RS',
'image' => new Image(externalPublicUrl: 'https://www.audi.de/content/dam/nemo/models/e-tron-gt/my-2021/1920x1080-gallery/1920x1080_AudiRS_e-tron_GT_19.jpg'), 'image' => new Image(externalPublicUrl: 'https://ev-database.org/img/auto/Audi_e-tron_GT_RS/Audi_e-tron_GT_RS-02@2x.jpg'),
'properties' => [ 'properties' => [
['type' => CarPropertyType::PRODUCTION_BEGIN, 'value' => new Date(1, 1, 2021)], new Production(productionBegin: new Date(1, 1, 2021)),
['type' => CarPropertyType::DRIVING_CHARACTERISTICS_POWER, 'value' => new Power(475)], new MotorPower(new Power(475)),
['type' => CarPropertyType::DRIVING_CHARACTERISTICS_ACCELERATION, 'value' => new Acceleration(3.3)], new Acceleration(3.3),
['type' => CarPropertyType::DRIVING_CHARACTERISTICS_TOP_SPEED, 'value' => new Speed(250)], new TopSpeed(new Speed(250)),
['type' => CarPropertyType::DRIVING_CHARACTERISTICS_CONSUMPTION, 'value' => new Consumption(new Energy(19.6))], new AverageConsumption(new Consumption(new Energy(19.6))),
['type' => CarPropertyType::BATTERY_USABLE_CAPACITY, 'value' => new Energy(83.7)], new BatteryCapacity(new Energy(83.7), new Energy(93.4)),
['type' => CarPropertyType::BATTERY_TOTAL_CAPACITY, 'value' => new Energy(93.4)], new BatteryType(CellChemistry::LithiumNickelManganeseOxide, 'PPE Platform', 'LG Energy Solution'),
['type' => CarPropertyType::BATTERY_CELL_CHEMISTRY, 'value' => CellChemistry::LithiumNickelManganeseOxide], new ChargingSpeed(new Power(270), new Power(270)),
['type' => CarPropertyType::BATTERY_MODEL, 'value' => 'PPE Platform'], new RangeSpecification(new NefzRange(new Range(487)), new WltpRange(new Range(472))),
['type' => CarPropertyType::BATTERY_MANUFACTURER, 'value' => 'LG Energy Solution'], new CatalogPrice(new Price(142900, Currency::euro())),
['type' => CarPropertyType::CHARGING_TOP_CHARGING_SPEED, 'value' => new Power(270)],
['type' => CarPropertyType::RANGE_WLTP, 'value' => new Range(472)],
['type' => CarPropertyType::RANGE_NEFZ, 'value' => new Range(487)],
['type' => CarPropertyType::CATALOG_PRICE, 'value' => new Price(142900, Currency::euro())],
]
]
]
]
]
],
[
'brand' => 'Mercedes-Benz',
'models' => [
[
'model' => 'EQS',
'revisions' => [
[
'revision' => '450+',
'image' => new Image(externalPublicUrl: 'https://www.mercedes-benz.de/content/germany/de/mercedes-benz/vehicles/passenger-cars/eqs/sedan/overview/_jcr_content/root/responsivegrid/simple_stage.component.damq2.3318859008536.jpg'),
'properties' => [
['type' => CarPropertyType::PRODUCTION_BEGIN, 'value' => new Date(1, 1, 2021)],
['type' => CarPropertyType::DRIVING_CHARACTERISTICS_POWER, 'value' => new Power(245)],
['type' => CarPropertyType::DRIVING_CHARACTERISTICS_ACCELERATION, 'value' => new Acceleration(6.2)],
['type' => CarPropertyType::DRIVING_CHARACTERISTICS_TOP_SPEED, 'value' => new Speed(210)],
['type' => CarPropertyType::DRIVING_CHARACTERISTICS_CONSUMPTION, 'value' => new Consumption(new Energy(15.7))],
['type' => CarPropertyType::BATTERY_USABLE_CAPACITY, 'value' => new Energy(90.0)],
['type' => CarPropertyType::BATTERY_TOTAL_CAPACITY, 'value' => new Energy(107.8)],
['type' => CarPropertyType::BATTERY_CELL_CHEMISTRY, 'value' => CellChemistry::LithiumNickelManganeseOxide],
['type' => CarPropertyType::BATTERY_MODEL, 'value' => 'EVA Platform'],
['type' => CarPropertyType::BATTERY_MANUFACTURER, 'value' => 'CATL'],
['type' => CarPropertyType::CHARGING_TOP_CHARGING_SPEED, 'value' => new Power(200)],
['type' => CarPropertyType::RANGE_WLTP, 'value' => new Range(756)],
['type' => CarPropertyType::RANGE_NEFZ, 'value' => new Range(770)],
['type' => CarPropertyType::CATALOG_PRICE, 'value' => new Price(106374, Currency::euro())],
]
]
]
]
]
],
[
'brand' => 'Volkswagen',
'models' => [
[
'model' => 'ID.4',
'revisions' => [
[
'revision' => 'Pro',
'image' => new Image(externalPublicUrl: 'https://www.volkswagen.de/content/dam/vw-ngw/vw_pkw/importers/de/models/id-4/gallery/id4-gallery-exterior-01-16x9.jpg'),
'properties' => [
['type' => CarPropertyType::PRODUCTION_BEGIN, 'value' => new Date(1, 1, 2020)],
['type' => CarPropertyType::DRIVING_CHARACTERISTICS_POWER, 'value' => new Power(150)],
['type' => CarPropertyType::DRIVING_CHARACTERISTICS_ACCELERATION, 'value' => new Acceleration(8.5)],
['type' => CarPropertyType::DRIVING_CHARACTERISTICS_TOP_SPEED, 'value' => new Speed(160)],
['type' => CarPropertyType::DRIVING_CHARACTERISTICS_CONSUMPTION, 'value' => new Consumption(new Energy(16.3))],
['type' => CarPropertyType::BATTERY_USABLE_CAPACITY, 'value' => new Energy(77.0)],
['type' => CarPropertyType::BATTERY_TOTAL_CAPACITY, 'value' => new Energy(82.0)],
['type' => CarPropertyType::BATTERY_CELL_CHEMISTRY, 'value' => CellChemistry::LithiumNickelManganeseOxide],
['type' => CarPropertyType::BATTERY_MODEL, 'value' => 'MEB Platform'],
['type' => CarPropertyType::BATTERY_MANUFACTURER, 'value' => 'LG Energy Solution'],
['type' => CarPropertyType::CHARGING_TOP_CHARGING_SPEED, 'value' => new Power(125)],
['type' => CarPropertyType::RANGE_WLTP, 'value' => new Range(520)],
['type' => CarPropertyType::RANGE_NEFZ, 'value' => new Range(549)],
['type' => CarPropertyType::CATALOG_PRICE, 'value' => new Price(51515, Currency::euro())],
] ]
] ]
] ]
@ -358,22 +464,310 @@ class LoadFixtures extends Command
'revisions' => [ 'revisions' => [
[ [
'revision' => 'Turbo S', 'revision' => 'Turbo S',
'image' => new Image(externalPublicUrl: 'https://www.porsche.com/germany/models/taycan/taycan-models/turbo-s/_jcr_content/par/twocolumnlayout/par_left/image.transform.porsche-model-desktop-xl.jpg'), 'image' => null,
'properties' => [ 'properties' => [
['type' => CarPropertyType::PRODUCTION_BEGIN, 'value' => new Date(1, 1, 2019)], new Production(productionBegin: new Date(1, 1, 2019)),
['type' => CarPropertyType::DRIVING_CHARACTERISTICS_POWER, 'value' => new Power(560)], new MotorPower(new Power(560)),
['type' => CarPropertyType::DRIVING_CHARACTERISTICS_ACCELERATION, 'value' => new Acceleration(2.8)], new Acceleration(2.8),
['type' => CarPropertyType::DRIVING_CHARACTERISTICS_TOP_SPEED, 'value' => new Speed(260)], new TopSpeed(new Speed(260)),
['type' => CarPropertyType::DRIVING_CHARACTERISTICS_CONSUMPTION, 'value' => new Consumption(new Energy(23.7))], new AverageConsumption(new Consumption(new Energy(23.7))),
['type' => CarPropertyType::BATTERY_USABLE_CAPACITY, 'value' => new Energy(83.7)], new BatteryCapacity(new Energy(83.7), new Energy(93.4)),
['type' => CarPropertyType::BATTERY_TOTAL_CAPACITY, 'value' => new Energy(93.4)], new BatteryType(CellChemistry::LithiumNickelManganeseOxide, 'J1 Platform', 'LG Energy Solution'),
['type' => CarPropertyType::BATTERY_CELL_CHEMISTRY, 'value' => CellChemistry::LithiumNickelManganeseOxide], new ChargingSpeed(new Power(270), new Power(270)),
['type' => CarPropertyType::BATTERY_MODEL, 'value' => 'J1 Platform'], new RangeSpecification(new NefzRange(new Range(452)), new WltpRange(new Range(440))),
['type' => CarPropertyType::BATTERY_MANUFACTURER, 'value' => 'LG Energy Solution'], new CatalogPrice(new Price(185456, Currency::euro())),
['type' => CarPropertyType::CHARGING_TOP_CHARGING_SPEED, 'value' => new Power(270)], ]
['type' => CarPropertyType::RANGE_WLTP, 'value' => new Range(440)], ]
['type' => CarPropertyType::RANGE_NEFZ, 'value' => new Range(452)], ]
['type' => CarPropertyType::CATALOG_PRICE, 'value' => new Price(185456, Currency::euro())], ]
]
],
[
'brand' => 'Ford',
'models' => [
[
'model' => 'Mustang Mach-E',
'revisions' => [
[
'revision' => 'Extended Range RWD',
'image' => null,
'properties' => [
new Production(productionBegin: new Date(1, 1, 2021)),
new MotorPower(new Power(269)),
new Acceleration(7.0),
new TopSpeed(new Speed(180)),
new AverageConsumption(new Consumption(new Energy(18.7))),
new BatteryCapacity(new Energy(88.0), new Energy(98.8)),
new BatteryType(CellChemistry::LithiumNickelManganeseOxide, 'GE2 Platform', 'LG Energy Solution'),
new ChargingSpeed(new Power(150), new Power(150)),
new RangeSpecification(new NefzRange(new Range(610)), new WltpRange(new Range(600))),
new CatalogPrice(new Price(72900, Currency::euro())),
]
]
]
]
]
],
[
'brand' => 'Hyundai',
'models' => [
[
'model' => 'IONIQ 6',
'revisions' => [
[
'revision' => '77.4 kWh RWD',
'image' => null,
'properties' => [
new Production(productionBegin: new Date(7, 1, 2022)),
new MotorPower(new Power(229)),
new Acceleration(7.4),
new TopSpeed(new Speed(185)),
new AverageConsumption(new Consumption(new Energy(14.3))),
new BatteryCapacity(new Energy(77.4), new Energy(84.0)),
new BatteryType(CellChemistry::LithiumNickelManganeseOxide, 'E-GMP Platform', 'SK Innovation'),
new ChargingSpeed(new Power(233), new Power(233)),
new RangeSpecification(new NefzRange(new Range(614)), new WltpRange(new Range(614))),
new CatalogPrice(new Price(53900, Currency::euro())),
]
]
]
],
[
'model' => 'IONIQ 5',
'revisions' => [
[
'revision' => '77.4 kWh AWD',
'image' => null,
'properties' => [
new Production(productionBegin: new Date(2, 1, 2021)),
new MotorPower(new Power(239)),
new Acceleration(5.2),
new TopSpeed(new Speed(185)),
new AverageConsumption(new Consumption(new Energy(17.7))),
new BatteryCapacity(new Energy(77.4), new Energy(84.0)),
new BatteryType(CellChemistry::LithiumNickelManganeseOxide, 'E-GMP Platform', 'SK Innovation'),
new ChargingSpeed(new Power(233), new Power(233)),
new RangeSpecification(new NefzRange(new Range(507)), new WltpRange(new Range(507))),
new CatalogPrice(new Price(59900, Currency::euro())),
]
]
]
]
]
],
[
'brand' => 'Polestar',
'models' => [
[
'model' => '2',
'revisions' => [
[
'revision' => 'Long Range Single Motor',
'image' => null,
'properties' => [
new Production(productionBegin: new Date(1, 1, 2020)),
new MotorPower(new Power(170)),
new Acceleration(7.4),
new TopSpeed(new Speed(160)),
new AverageConsumption(new Consumption(new Energy(16.5))),
new BatteryCapacity(new Energy(78.0), new Energy(82.0)),
new BatteryType(CellChemistry::LithiumNickelManganeseOxide, 'CMA Platform', 'CATL'),
new ChargingSpeed(new Power(150), new Power(150)),
new RangeSpecification(new NefzRange(new Range(540)), new WltpRange(new Range(635))),
new CatalogPrice(new Price(51400, Currency::euro())),
]
]
]
],
[
'model' => '3',
'revisions' => [
[
'revision' => 'Long Range RWD',
'image' => null,
'properties' => [
new Production(productionBegin: new Date(10, 1, 2023)),
new MotorPower(new Power(220)),
new Acceleration(7.0),
new TopSpeed(new Speed(180)),
new AverageConsumption(new Consumption(new Energy(15.8))),
new BatteryCapacity(new Energy(78.0), new Energy(84.0)),
new BatteryType(CellChemistry::LithiumNickelManganeseOxide, 'SPA2 Platform', 'CATL'),
new ChargingSpeed(new Power(250), new Power(250)),
new RangeSpecification(new NefzRange(new Range(610)), new WltpRange(new Range(610))),
new CatalogPrice(new Price(73400, Currency::euro())),
]
]
]
]
]
],
[
'brand' => 'Volvo',
'models' => [
[
'model' => 'EX30',
'revisions' => [
[
'revision' => 'Extended Range Single Motor',
'image' => null,
'properties' => [
new Production(productionBegin: new Date(6, 1, 2023)),
new MotorPower(new Power(200)),
new Acceleration(5.3),
new TopSpeed(new Speed(180)),
new AverageConsumption(new Consumption(new Energy(14.8))),
new BatteryCapacity(new Energy(69.0), new Energy(72.0)),
new BatteryType(CellChemistry::LithiumIronPhosphate, 'SEA Platform', 'CATL'),
new ChargingSpeed(new Power(153), new Power(153)),
new RangeSpecification(new NefzRange(new Range(476)), new WltpRange(new Range(476))),
new CatalogPrice(new Price(41700, Currency::euro())),
]
]
]
],
[
'model' => 'XC40',
'revisions' => [
[
'revision' => 'Extended Range',
'image' => null,
'properties' => [
new Production(productionBegin: new Date(10, 1, 2021)),
new MotorPower(new Power(170)),
new Acceleration(7.3),
new TopSpeed(new Speed(160)),
new AverageConsumption(new Consumption(new Energy(18.8))),
new BatteryCapacity(new Energy(78.0), new Energy(82.0)),
new BatteryType(CellChemistry::LithiumNickelManganeseOxide, 'CMA Platform', 'CATL'),
new ChargingSpeed(new Power(150), new Power(150)),
new RangeSpecification(new NefzRange(new Range(425)), new WltpRange(new Range(425))),
new CatalogPrice(new Price(52950, Currency::euro())),
]
]
]
]
]
],
[
'brand' => 'Genesis',
'models' => [
[
'model' => 'GV70',
'revisions' => [
[
'revision' => 'Electrified AWD',
'image' => null,
'properties' => [
new Production(productionBegin: new Date(6, 1, 2022)),
new MotorPower(new Power(360)),
new Acceleration(4.2),
new TopSpeed(new Speed(225)),
new AverageConsumption(new Consumption(new Energy(20.9))),
new BatteryCapacity(new Energy(77.4), new Energy(84.0)),
new BatteryType(CellChemistry::LithiumNickelManganeseOxide, 'E-GMP Platform', 'SK Innovation'),
new ChargingSpeed(new Power(233), new Power(233)),
new RangeSpecification(new NefzRange(new Range(455)), new WltpRange(new Range(455))),
new CatalogPrice(new Price(74800, Currency::euro())),
]
]
]
]
]
],
[
'brand' => 'Lucid',
'models' => [
[
'model' => 'Air',
'revisions' => [
[
'revision' => 'Dream Edition Range',
'image' => null,
'properties' => [
new Production(productionBegin: new Date(10, 1, 2021)),
new MotorPower(new Power(696)),
new Acceleration(2.5),
new TopSpeed(new Speed(270)),
new AverageConsumption(new Consumption(new Energy(13.8))),
new BatteryCapacity(new Energy(113.0), new Energy(118.0)),
new BatteryType(CellChemistry::LithiumNickelManganeseOxide, 'LCAP Platform', 'Samsung SDI'),
new ChargingSpeed(new Power(300), new Power(300)),
new RangeSpecification(new NefzRange(new Range(883)), new WltpRange(new Range(883))),
new CatalogPrice(new Price(218000, Currency::euro())),
]
]
]
]
]
],
[
'brand' => 'Nissan',
'models' => [
[
'model' => 'Ariya',
'revisions' => [
[
'revision' => '87 kWh FWD',
'image' => null,
'properties' => [
new Production(productionBegin: new Date(7, 1, 2022)),
new MotorPower(new Power(178)),
new Acceleration(7.5),
new TopSpeed(new Speed(160)),
new AverageConsumption(new Consumption(new Energy(17.6))),
new BatteryCapacity(new Energy(87.0), new Energy(91.0)),
new BatteryType(CellChemistry::LithiumNickelManganeseOxide, 'CMF-EV Platform', 'Envision AESC'),
new ChargingSpeed(new Power(130), new Power(130)),
new RangeSpecification(new NefzRange(new Range(533)), new WltpRange(new Range(533))),
new CatalogPrice(new Price(59990, Currency::euro())),
]
]
]
]
]
],
[
'brand' => 'BYD',
'models' => [
[
'model' => 'Tang',
'revisions' => [
[
'revision' => '86.4 kWh AWD',
'image' => null,
'properties' => [
new Production(productionBegin: new Date(1, 1, 2023)),
new MotorPower(new Power(380)),
new Acceleration(4.6),
new TopSpeed(new Speed(180)),
new AverageConsumption(new Consumption(new Energy(17.9))),
new BatteryCapacity(new Energy(86.4), new Energy(89.0)),
new BatteryType(CellChemistry::LithiumIronPhosphate, 'e-Platform 3.0', 'BYD'),
new ChargingSpeed(new Power(170), new Power(170)),
new RangeSpecification(new NefzRange(new Range(530)), new WltpRange(new Range(530))),
new CatalogPrice(new Price(72990, Currency::euro())),
]
]
]
],
[
'model' => 'Atto 3',
'revisions' => [
[
'revision' => '60.48 kWh',
'image' => null,
'properties' => [
new Production(productionBegin: new Date(9, 1, 2022)),
new MotorPower(new Power(150)),
new Acceleration(7.3),
new TopSpeed(new Speed(160)),
new AverageConsumption(new Consumption(new Energy(15.4))),
new BatteryCapacity(new Energy(60.48), new Energy(64.0)),
new BatteryType(CellChemistry::LithiumIronPhosphate, 'e-Platform 3.0', 'BYD'),
new ChargingSpeed(new Power(88), new Power(88)),
new RangeSpecification(new NefzRange(new Range(420)), new WltpRange(new Range(420))),
new CatalogPrice(new Price(44490, Currency::euro())),
] ]
] ]
] ]

View File

@ -0,0 +1,39 @@
<?php
namespace App\Application\DataCollector;
use App\Domain\AI\AIClient;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\DataCollector\DataCollector;
class AiChatDataCollector extends DataCollector
{
public function __construct(
private readonly AIClient $aiClient,
) {
}
public function collect(Request $request, Response $response, ?\Throwable $exception = null): void
{
$this->data = $this->aiClient->getLog();
}
public function getName(): string
{
return 'ai_chat';
}
/**
* @return array<mixed>
*/
public function getLog(): array
{
return $this->data;
}
public function reset(): void
{
$this->data = [];
}
}

View File

@ -11,6 +11,11 @@ class AIClient
{ {
private readonly OpenAI\Client $client; private readonly OpenAI\Client $client;
/**
* @var array<array{prompt: string, response: string}>
*/
private array $log = [];
public function __construct( public function __construct(
private readonly string $apiKey, private readonly string $apiKey,
) { ) {
@ -20,15 +25,48 @@ class AIClient
public function generateText(string $prompt): string public function generateText(string $prompt): string
{ {
$response = $this->client->chat()->create([ $response = $this->client->chat()->create([
'model' => 'gpt-4o-mini', 'model' => 'gpt-4.1-nano-2025-04-14',
'messages' => [ 'messages' => [
['role' => 'user', 'content' => $prompt], ['role' => 'user', 'content' => $prompt],
], ],
]); ]);
$this->log[] = [
'prompt' => $prompt,
'response' => $response->choices[0]->message->content ?? '',
];
return $response->choices[0]->message->content ?? ''; return $response->choices[0]->message->content ?? '';
} }
/**
* @return array<mixed>
*/
public function generateJson(string $prompt): array
{
$response = $this->client->chat()->create([
'model' => 'gpt-4.1-nano',
'messages' => [
['role' => 'user', 'content' => $prompt],
],
'response_format' => [
'type' => 'json_object',
],
]);
$this->log[] = [
'prompt' => $prompt,
'response' => $response->choices[0]->message->content ?? '',
];
$result = json_decode($response->choices[0]->message->content ?? '', true) ?? [];
if (!is_array($result)) {
throw new \Exception('Invalid JSON response from AI');
}
return $result;
}
public function embedTextLarge(string $text): LargeEmbeddingVector public function embedTextLarge(string $text): LargeEmbeddingVector
{ {
$response = $this->client->embeddings()->create([ $response = $this->client->embeddings()->create([
@ -48,4 +86,17 @@ class AIClient
return new SmallEmbeddingVector(new Vector($response->embeddings[0]->embedding)); return new SmallEmbeddingVector(new Vector($response->embeddings[0]->embedding));
} }
/**
* @return array<array{prompt: string, response: string}>
*/
public function getLog(): array
{
return $this->log;
}
public function resetLog(): void
{
$this->log = [];
}
} }

View File

@ -4,12 +4,13 @@ namespace App\Domain\ContentManagement;
use App\Domain\AI\AIClient; use App\Domain\AI\AIClient;
use App\Domain\Model\Embedding\Embedding; use App\Domain\Model\Embedding\Embedding;
use App\Domain\Model\Brand; use App\Domain\Model\Cars\Brand;
use App\Domain\Model\CarProperty; use App\Domain\Model\Cars\CarModel;
use App\Domain\Model\CarRevision; use App\Domain\Model\Cars\CarProperty;
use App\Domain\Model\Cars\CarPropertyValues\V1\CarPropertyValue;
use App\Domain\Model\Cars\CarRevision;
use App\Domain\Model\Id\EmbeddingId; use App\Domain\Model\Id\EmbeddingId;
use App\Domain\Repository\EmbeddingRepository; use App\Domain\Repository\EmbeddingRepository;
use Stringable;
class CarPropertyEmbedder class CarPropertyEmbedder
{ {
@ -19,16 +20,17 @@ class CarPropertyEmbedder
) { ) {
} }
public function createPersistedEmbedding(CarProperty $carProperty, ?CarRevision $carRevision, ?Brand $brand): ?Embedding public function createPersistedEmbedding(CarProperty $carProperty, ?CarRevision $carRevision, ?CarModel $carModel, ?Brand $brand): ?Embedding
{ {
if (!($carProperty->value instanceof Stringable)) { $text = $carProperty->value->humanReadable();
return null;
}
$text = $carProperty->type->humanReadable() . ': ' . (string) $carProperty->value;
if ($carRevision !== null) { if ($carRevision !== null) {
$text .= ' - ' . $carRevision->name; $text .= ' - ' . $carRevision->name;
} }
if ($carModel !== null) {
$text .= ', ' . $carModel->name;
}
if ($brand !== null) { if ($brand !== null) {
$text .= ', ' . $brand->name; $text .= ', ' . $brand->name;
} }

View File

@ -1,22 +0,0 @@
<?php
namespace App\Domain\Model\Battery;
use App\Domain\Model\Value\Energy;
class BatteryProperties
{
public function __construct(
public readonly Energy $usableCapacity,
public readonly Energy $totalCapacity,
public readonly CellChemistry $cellChemistry = CellChemistry::LithiumIronPhosphate,
public readonly string $model = '4680',
public readonly string $manufacturer = 'Tesla',
) {
}
public function __toString(): string
{
return $this->usableCapacity->__toString();
}
}

View File

@ -1,55 +0,0 @@
<?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

@ -1,95 +0,0 @@
<?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';
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::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

@ -1,8 +1,9 @@
<?php <?php
namespace App\Domain\Model; namespace App\Domain\Model\Cars;
use App\Domain\Model\Id\BrandId; use App\Domain\Model\Id\BrandId;
use App\Domain\Model\EmbeddingCollection;
final readonly class Brand final readonly class Brand
{ {

View File

@ -1,6 +1,6 @@
<?php <?php
namespace App\Domain\Model; namespace App\Domain\Model\Cars;
class BrandCollection class BrandCollection
{ {

View File

@ -1,6 +1,6 @@
<?php <?php
namespace App\Domain\Model; namespace App\Domain\Model\Cars;
use App\Domain\Model\Id\BrandId; use App\Domain\Model\Id\BrandId;
use App\Domain\Model\Id\CarModelId; use App\Domain\Model\Id\CarModelId;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace App\Domain\Model; namespace App\Domain\Model\Cars;
class CarModelCollection class CarModelCollection
{ {

View File

@ -1,18 +1,24 @@
<?php <?php
namespace App\Domain\Model; namespace App\Domain\Model\Cars;
use App\Domain\Model\Cars\CarPropertyValues\V1\CarPropertyValue;
use App\Domain\Model\Id\CarPropertyId; use App\Domain\Model\Id\CarPropertyId;
use App\Domain\Model\Id\CarRevisionId; use App\Domain\Model\Id\CarRevisionId;
use App\Domain\Model\Id\EmbeddingIdCollection; use App\Domain\Model\Id\EmbeddingIdCollection;
/**
* @template T of CarPropertyValue
*/
final readonly class CarProperty final readonly class CarProperty
{ {
/**
* @param T $value
*/
public function __construct( public function __construct(
public readonly CarPropertyId $carPropertyId, public readonly CarPropertyId $carPropertyId,
public readonly CarRevisionId $carRevisionId, public readonly CarRevisionId $carRevisionId,
public CarPropertyType $type, public CarPropertyValue $value,
public mixed $value,
public readonly EmbeddingIdCollection $embeddings = new EmbeddingIdCollection([]), public readonly EmbeddingIdCollection $embeddings = new EmbeddingIdCollection([]),
) {} ) {}
} }

View File

@ -0,0 +1,107 @@
<?php
namespace App\Domain\Model\Cars;
use App\Domain\Model\Cars\CarProperty;
use App\Domain\Model\Cars\CarPropertyValues\V1\CarPropertyValue;
/**
* @template TItem of CarPropertyValue
*/
final class CarPropertyCollection
{
/**
* @param CarProperty<TItem>[] $properties
*/
public function __construct(
private array $properties = [],
) {}
/**
* @param CarProperty<TItem> $property
*/
public function add(CarProperty $property): void
{
$this->properties[] = $property;
}
public function count(): int
{
return count($this->properties);
}
/**
* @return CarProperty<TItem>[]
*/
public function array(): array
{
return $this->properties;
}
/**
* @return CarPropertyCollection<TItem>
*/
public function sortByRevision(): CarPropertyCollection
{
$properties = $this->properties;
usort($properties, fn(CarProperty $a, CarProperty $b) => $a->carRevisionId->value <=> $b->carRevisionId->value);
return new CarPropertyCollection($properties);
}
/**
* @template T of CarPropertyValue
*
* @param class-string<T> $type
*
* @return CarPropertyCollection<T>
*/
public function get(string $type): CarPropertyCollection
{
/** @var CarPropertyCollection<T> $collection */
$collection = new CarPropertyCollection(array_filter($this->properties, fn(CarProperty $property) => $property->value instanceof $type));
return $collection;
}
/**
* @template T of CarPropertyValue
*
* @param class-string<T> $type
*
* @return CarProperty<T>|null
*/
public function getOne(string $type): ?CarProperty
{
return array_values($this->get($type)->array())[0] ?? null;
}
/**
* @template T of CarPropertyValue
*
* @param class-string<T> $type
*
* @return T|null
*/
public function getOneValue(string $type): mixed
{
$value = $this->getOne($type)?->value;
if ($value === null) {
return null;
}
return $value;
}
/**
* @param class-string<TItem>[] $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,21 @@
<?php
namespace App\Domain\Model\Cars\CarPropertyValues\V1;
final readonly class Acceleration extends CarPropertyValue
{
public function __construct(
public readonly float $secondsFrom0To100,
) {
}
public function humanReadable(): string
{
return 'Acceleration: ' . $this->secondsFrom0To100 . ' sec (0-100 km/h)';
}
public function seconds(): float
{
return $this->secondsFrom0To100;
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Domain\Model\Cars\CarPropertyValues\V1;
use App\Domain\Model\Value\Consumption;
final readonly class AverageConsumption extends CarPropertyValue
{
public function __construct(
public readonly Consumption $consumption,
) {}
public function humanReadable(): string
{
return 'Average consumption: ' . $this->consumption->__toString();
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Domain\Model\Cars\CarPropertyValues\V1\Battery;
use App\Domain\Model\Value\Energy;
use App\Domain\Model\Cars\CarPropertyValues\V1\CarPropertyValue;
final readonly class BatteryCapacity extends CarPropertyValue
{
public function __construct(
public readonly Energy $usableCapacity,
public readonly Energy $totalCapacity,
) {}
public function humanReadable(): string
{
return "Usable capacity: " . $this->usableCapacity->__toString() . ", total capacity: " . $this->totalCapacity->__toString();
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Domain\Model\Cars\CarPropertyValues\V1\Battery;
use App\Domain\Model\Cars\CarPropertyValues\V1\CarPropertyValue;
final readonly class BatteryType extends CarPropertyValue
{
public function __construct(
public readonly CellChemistry $chemistry,
public readonly string $model,
public readonly string $manufacturer,
) {}
public function humanReadable(): string
{
return 'Battery type: ' . $this->chemistry->value . ' ' . $this->model . ' ' . $this->manufacturer;
}
}

View File

@ -1,8 +1,6 @@
<?php <?php
namespace App\Domain\Model\Battery; namespace App\Domain\Model\Cars\CarPropertyValues\V1\Battery;
use Stringable;
enum CellChemistry: string enum CellChemistry: string
{ {

View File

@ -0,0 +1,15 @@
<?php
namespace App\Domain\Model\Cars\CarPropertyValues\V1;
use Stringable;
abstract readonly class CarPropertyValue implements Stringable
{
abstract public function humanReadable(): string;
public function __toString(): string
{
return $this->humanReadable();
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Domain\Model\Cars\CarPropertyValues\V1;
use App\Domain\Model\Value\Price;
final readonly class CatalogPrice extends CarPropertyValue
{
public function __construct(
public readonly Price $price,
) {}
public function humanReadable(): string
{
return 'Catalog price: ' . $this->price->__toString();
}
}

View File

@ -2,9 +2,10 @@
namespace App\Domain\Model\Charging; namespace App\Domain\Model\Charging;
use App\Domain\Model\Cars\CarPropertyValues\V1\CarPropertyValue;
use App\Domain\Model\Value\Power; use App\Domain\Model\Value\Power;
final readonly class ChargeCurve final readonly class ChargeCurve extends CarPropertyValue
{ {
public function __construct( public function __construct(
public ?Power $averagePowerSoc0 = null, public ?Power $averagePowerSoc0 = null,
@ -19,4 +20,9 @@ final readonly class ChargeCurve
public ?Power $averagePowerSoc90 = null, public ?Power $averagePowerSoc90 = null,
public ?Power $averagePowerSoc100 = null, public ?Power $averagePowerSoc100 = null,
) {} ) {}
public function humanReadable(): string
{
return 'Charge curve: ' . $this->averagePowerSoc0 . ' ' . $this->averagePowerSoc10 . ' ' . $this->averagePowerSoc20 . ' ' . $this->averagePowerSoc30 . ' ' . $this->averagePowerSoc40 . ' ' . $this->averagePowerSoc50 . ' ' . $this->averagePowerSoc60 . ' ' . $this->averagePowerSoc70 . ' ' . $this->averagePowerSoc80 . ' ' . $this->averagePowerSoc90 . ' ' . $this->averagePowerSoc100;
}
} }

View File

@ -0,0 +1,50 @@
<?php
namespace App\Domain\Model\Charging;
use App\Domain\Model\Cars\CarPropertyValues\V1\CarPropertyValue;
final readonly class ChargeTimeProperties extends CarPropertyValue
{
public function __construct(
public ?int $minutesFrom0To100 = null,
public ?int $minutesFrom0To70 = null,
public ?int $minutesFrom10To70 = null,
public ?int $minutesFrom20To70 = null,
public ?int $minutesFrom10To80 = null,
public ?int $minutesFrom20To80 = null,
public ?int $minutesFrom10To90 = null,
public ?int $minutesFrom20To90 = null,
) {}
public function humanReadable(): string
{
$properties = [];
if ($this->minutesFrom0To100 !== null) {
$properties[] = $this->minutesFrom0To100 . ' min (0-100%)';
}
if ($this->minutesFrom0To70 !== null) {
$properties[] = $this->minutesFrom0To70 . ' min (0-70%)';
}
if ($this->minutesFrom10To70 !== null) {
$properties[] = $this->minutesFrom10To70 . ' min (10-70%)';
}
if ($this->minutesFrom20To70 !== null) {
$properties[] = $this->minutesFrom20To70 . ' min (20-70%)';
}
if ($this->minutesFrom10To80 !== null) {
$properties[] = $this->minutesFrom10To80 . ' min (10-80%)';
}
if ($this->minutesFrom20To80 !== null) {
$properties[] = $this->minutesFrom20To80 . ' min (20-80%)';
}
if ($this->minutesFrom10To90 !== null) {
$properties[] = $this->minutesFrom10To90 . ' min (10-90%)';
}
if ($this->minutesFrom20To90 !== null) {
$properties[] = $this->minutesFrom20To90 . ' min (20-90%)';
}
return 'Charge time properties: ' . implode(', ', $properties);
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Domain\Model\Charging;
use App\Domain\Model\Cars\CarPropertyValues\V1\CarPropertyValue;
final readonly class ChargingConnectivity extends CarPropertyValue
{
/**
* @param ConnectorType[] $connectorTypes
*/
public function __construct(
public readonly ?bool $is400v = null,
public readonly ?bool $is800v = null,
public readonly ?bool $plugAndCharge = null,
public readonly array $connectorTypes = [],
) {}
public function humanReadable(): string
{
$properties = [];
if ($this->is400v !== null) {
$properties[] = '400V';
}
if ($this->is800v !== null) {
$properties[] = '800V';
}
if ($this->plugAndCharge !== null) {
$properties[] = 'Plug and Charge: ' . ($this->plugAndCharge ? 'Yes' : 'No');
}
foreach ($this->connectorTypes as $connectorType) {
$properties[] = $connectorType->value;
}
return 'Charging connectivity: ' . implode(', ', $properties);
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Domain\Model\Cars\CarPropertyValues\V1\Charging;
use App\Domain\Model\Cars\CarPropertyValues\V1\CarPropertyValue;
use App\Domain\Model\Value\Power;
final readonly class ChargingSpeed extends CarPropertyValue
{
public function __construct(
public readonly Power $dcMaxKw,
public readonly Power $acMaxKw,
) {
}
public function humanReadable(): string
{
return 'Charging speed: DC: ' . $this->dcMax() . ' AC: ' . $this->acMax();
}
public function dcMax(): Power
{
return $this->dcMaxKw;
}
public function acMax(): Power
{
return $this->acMaxKw;
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Domain\Model\Cars\CarPropertyValues\V1;
use App\Domain\Model\Value\Power;
final readonly class MotorPower extends CarPropertyValue
{
public function __construct(
public readonly Power $power,
) {}
public function humanReadable(): string
{
return 'Motor power: ' . $this->power->__toString();
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Domain\Model\Cars\CarPropertyValues\V1;
use App\Domain\Model\Value\Date;
final readonly class Production extends CarPropertyValue
{
public function __construct(
public ?Date $productionBegin = null,
public ?Date $productionEnd = null,
) {}
public function humanReadable(): string
{
if ($this->productionBegin === null && $this->productionEnd === null) {
return '';
}
if ($this->productionBegin === null) {
return 'Production end: ' . $this->productionEnd?->__toString();
}
if ($this->productionEnd === null) {
return 'Production begin: ' . $this->productionBegin->__toString();
}
return 'Production begin: ' . $this->productionBegin->__toString() . ' - End: ' . $this->productionEnd->__toString();
}
}

View File

@ -9,4 +9,9 @@ final readonly class NefzRange
public function __construct( public function __construct(
public readonly Range $range, public readonly Range $range,
) {} ) {}
public function __toString(): string
{
return $this->range->__toString();
}
} }

View File

@ -9,4 +9,9 @@ final readonly class WltpRange
public function __construct( public function __construct(
public readonly Range $range, public readonly Range $range,
) {} ) {}
public function __toString(): string
{
return $this->range->__toString();
}
} }

View File

@ -0,0 +1,35 @@
<?php
namespace App\Domain\Model\Cars\CarPropertyValues\V1;
use App\Domain\Model\Range\NefzRange;
use App\Domain\Model\Range\WltpRange;
final readonly class RangeSpecification extends CarPropertyValue
{
public function __construct(
public readonly ?NefzRange $nefzRange = null,
public readonly ?WltpRange $wltpRange = null,
) {
if ($this->nefzRange === null && $this->wltpRange === null) {
throw new \InvalidArgumentException('At least one range must be specified');
}
}
public function humanReadable(): string
{
if ($this->nefzRange === null && $this->wltpRange === null) {
return 'No Range specified';
}
if ($this->nefzRange === null) {
return 'WLTP Range: ' . $this->wltpRange?->__toString();
}
if ($this->wltpRange === null) {
return 'NEFZ Range: ' . $this->nefzRange->__toString();
}
return '';
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Domain\Model\Cars\CarPropertyValues\V1;
use App\Domain\Model\Value\Speed;
final readonly class TopSpeed extends CarPropertyValue
{
public function __construct(
public readonly Speed $speed,
) {}
public function humanReadable(): string
{
return 'Top speed: ' . $this->speed->__toString();
}
}

View File

@ -1,6 +1,6 @@
<?php <?php
namespace App\Domain\Model; namespace App\Domain\Model\Cars;
use App\Domain\Model\Id\CarModelId; use App\Domain\Model\Id\CarModelId;
use App\Domain\Model\Id\CarRevisionId; use App\Domain\Model\Id\CarRevisionId;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace App\Domain\Model; namespace App\Domain\Model\Cars;
class CarRevisionCollection class CarRevisionCollection
{ {

View File

@ -1,17 +0,0 @@
<?php
namespace App\Domain\Model\Charging;
final readonly class ChargeTimeProperties
{
public function __construct(
public ?int $minutesFrom0To100 = null,
public ?int $minutesFrom0To70 = null,
public ?int $minutesFrom10To70 = null,
public ?int $minutesFrom20To70 = null,
public ?int $minutesFrom10To80 = null,
public ?int $minutesFrom20To80 = null,
public ?int $minutesFrom10To90 = null,
public ?int $minutesFrom20To90 = null,
) {}
}

View File

@ -1,16 +0,0 @@
<?php
namespace App\Domain\Model\Charging;
final readonly class ChargingConnectivity
{
/**
* @param ConnectorType[] $connectorTypes
*/
public function __construct(
public readonly ?bool $is400v = null,
public readonly ?bool $is800v = null,
public readonly ?bool $plugAndCharge = null,
public readonly array $connectorTypes = [],
) {}
}

View File

@ -1,16 +0,0 @@
<?php
namespace App\Domain\Model\Charging;
use App\Domain\Model\Value\Power;
final readonly class ChargingProperties
{
public function __construct(
public ?Power $topChargingSpeed = null,
public ?ChargeCurve $chargeCurve = null,
public ?ChargeTimeProperties $chargeTimeProperties = null,
public ?ChargingConnectivity $chargingConnectivity = null,
) {
}
}

View File

@ -1,21 +0,0 @@
<?php
namespace App\Domain\Model\Value;
class Acceleration
{
public function __construct(
public readonly float $secondsFrom0To100,
) {
}
public function __toString(): string
{
return $this->secondsFrom0To100 . ' sec (0-100 km/h)';
}
public function seconds(): float
{
return $this->secondsFrom0To100;
}
}

View File

@ -1,27 +0,0 @@
<?php
namespace App\Domain\Model\Value;
class ChargingSpeed
{
public function __construct(
public readonly Power $dcMaxKw,
public readonly Power $acMaxKw,
) {
}
public function __toString(): string
{
return $this->dcMax() . ' DC / ' . $this->acMax() . ' AC';
}
public function dcMax(): Power
{
return $this->dcMaxKw;
}
public function acMax(): Power
{
return $this->acMaxKw;
}
}

View File

@ -2,9 +2,9 @@
namespace App\Domain\Repository; namespace App\Domain\Repository;
use App\Domain\Model\Brand; use App\Domain\Model\Cars\Brand;
use App\Domain\Model\BrandCollection; use App\Domain\Model\Cars\BrandCollection;
use App\Domain\Model\CarModel; use App\Domain\Model\Cars\CarModel;
use App\Domain\Model\Id\BrandId; use App\Domain\Model\Id\BrandId;
interface BrandRepository interface BrandRepository

View File

@ -2,9 +2,9 @@
namespace App\Domain\Repository; namespace App\Domain\Repository;
use App\Domain\Model\CarModel; use App\Domain\Model\Cars\CarModel;
use App\Domain\Model\CarModelCollection; use App\Domain\Model\Cars\CarModelCollection;
use App\Domain\Model\CarRevision; use App\Domain\Model\Cars\CarRevision;
use App\Domain\Model\Id\CarModelId; use App\Domain\Model\Id\CarModelId;
use App\Domain\Model\Id\BrandId; use App\Domain\Model\Id\BrandId;

View File

@ -2,14 +2,16 @@
namespace App\Domain\Repository; namespace App\Domain\Repository;
use App\Domain\Model\CarProperty; use App\Domain\Model\Cars\CarProperty;
use App\Domain\Model\CarPropertyCollection; use App\Domain\Model\Cars\CarPropertyCollection;
use App\Domain\Model\CarRevision; use App\Domain\Model\Cars\CarRevision;
use App\Domain\Model\Id\CarRevisionId; use App\Domain\Model\Id\CarPropertyId;
use App\Domain\Model\Id\EmbeddingId; use App\Domain\Model\Id\EmbeddingId;
interface CarPropertyRepository interface CarPropertyRepository
{ {
public function findById(CarPropertyId $carPropertyId): ?CarProperty;
public function findByCarRevision(CarRevision $carRevision): CarPropertyCollection; public function findByCarRevision(CarRevision $carRevision): CarPropertyCollection;
/** /**

View File

@ -2,8 +2,8 @@
namespace App\Domain\Repository; namespace App\Domain\Repository;
use App\Domain\Model\CarRevision; use App\Domain\Model\Cars\CarRevision;
use App\Domain\Model\CarRevisionCollection; use App\Domain\Model\Cars\CarRevisionCollection;
use App\Domain\Model\Id\CarRevisionId; use App\Domain\Model\Id\CarRevisionId;
use App\Domain\Model\Id\CarModelId; use App\Domain\Model\Id\CarModelId;

View File

@ -19,14 +19,14 @@ interface EmbeddingRepository
* @param int $limit * @param int $limit
* @return EmbeddingCollection * @return EmbeddingCollection
*/ */
public function searchByLargeEmbeddingVector(LargeEmbeddingVector $embeddingVector, int $limit = 10): EmbeddingCollection; public function searchByLargeEmbeddingVector(LargeEmbeddingVector $embeddingVector, int $limit = 100): EmbeddingCollection;
/** /**
* @param SmallEmbeddingVector $vector * @param SmallEmbeddingVector $vector
* @param int $limit * @param int $limit
* @return EmbeddingCollection * @return EmbeddingCollection
*/ */
public function searchBySmallEmbeddingVector(SmallEmbeddingVector $vector, int $limit = 10): EmbeddingCollection; public function searchBySmallEmbeddingVector(SmallEmbeddingVector $vector, int $limit = 100): EmbeddingCollection;
/** /**
* @param string $phrase * @param string $phrase

View File

@ -0,0 +1,83 @@
<?php
namespace App\Domain\Repository\Loader;
use App\Domain\Model\Id\CarModelId;
use App\Domain\Model\Id\CarRevisionId;
use App\Domain\Repository\BrandRepository;
use App\Domain\Repository\CarModelRepository;
use App\Domain\Repository\CarPropertyRepository;
use App\Domain\Repository\CarRevisionRepository;
use App\Domain\Model\Cars\CarRevision;
use App\Domain\Model\Id\CarPropertyId;
final readonly class FullCarLoader
{
public function __construct(
private readonly CarRevisionRepository $carRevisionRepository,
private readonly CarModelRepository $carModelRepository,
private readonly CarPropertyRepository $carPropertyRepository,
private readonly BrandRepository $brandRepository,
) {}
public function loadRevisionByCarPropertyId(CarPropertyId $carPropertyId): FullCarRevision
{
$carProperty = $this->carPropertyRepository->findById($carPropertyId);
if ($carProperty === null) {
throw new \InvalidArgumentException('Car property not found');
}
return $this->loadRevision($carProperty->carRevisionId);
}
public function loadRevision(CarRevisionId $carRevisionId): FullCarRevision
{
$carRevision = $this->carRevisionRepository->findById($carRevisionId);
if ($carRevision === null) {
throw new \InvalidArgumentException('Car revision not found');
}
$carModel = $this->carModelRepository->findById($carRevision->carModelId);
if ($carModel === null) {
throw new \InvalidArgumentException('Car model not found');
}
$brand = $this->brandRepository->findById($carModel->brandId);
if ($brand === null) {
throw new \InvalidArgumentException('Brand not found');
}
$carProperties = $this->carPropertyRepository->findByCarRevision($carRevision);
return new FullCarRevision(
$carRevision,
$carModel,
$brand,
$carProperties,
);
}
public function loadModel(CarModelId $carModelId): FullCarModel
{
$carModel = $this->carModelRepository->findById($carModelId);
if ($carModel === null) {
throw new \InvalidArgumentException('Car model not found');
}
$brand = $this->brandRepository->findById($carModel->brandId);
if ($brand === null) {
throw new \InvalidArgumentException('Brand not found');
}
$carRevisions = $this->carRevisionRepository->findByCarModelId($carModelId);
/** @var FullCarRevision[] $fullCarRevisions */
$fullCarRevisions = array_map(fn(CarRevision $rev) => $this->loadRevision($rev->carRevisionId), $carRevisions->array());
return new FullCarModel(
$carModel,
$brand,
$fullCarRevisions,
);
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace App\Domain\Repository\Loader;
use App\Domain\Model\Cars\Brand;
use App\Domain\Model\Cars\CarModel;
use App\Domain\Model\Cars\CarPropertyCollection;
use App\Domain\Model\Cars\CarRevision;
use App\Domain\Model\Cars\CarPropertyValues\V1\CarPropertyValue;
use App\Domain\Model\Id\CarRevisionId;
final readonly class FullCarModel
{
/**
* @param FullCarRevision[] $carRevisions
*/
public function __construct(
private readonly CarModel $carModel,
private readonly Brand $brand,
private readonly array $carRevisions = [],
) {}
public function getCarModel(): CarModel
{
return $this->carModel;
}
public function getBrand(): Brand
{
return $this->brand;
}
/**
* @return FullCarRevision[]
*/
public function getCarRevisions(): array
{
return $this->carRevisions;
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Domain\Repository\Loader;
use App\Domain\Model\Cars\Brand;
use App\Domain\Model\Cars\CarModel;
use App\Domain\Model\Cars\CarPropertyCollection;
use App\Domain\Model\Cars\CarRevision;
use App\Domain\Model\Cars\CarPropertyValues\V1\CarPropertyValue;
final readonly class FullCarRevision
{
/**
* @param CarPropertyCollection<CarPropertyValue> $carPropertyCollection
*/
public function __construct(
private readonly CarRevision $carRevision,
private readonly CarModel $carModel,
private readonly Brand $brand,
private readonly CarPropertyCollection $carPropertyCollection,
) {}
public function getCarRevision(): CarRevision
{
return $this->carRevision;
}
public function getCarModel(): CarModel
{
return $this->carModel;
}
public function getBrand(): Brand
{
return $this->brand;
}
/**
* @return CarPropertyCollection<CarPropertyValue>
*/
public function getCarPropertyCollection(): CarPropertyCollection
{
return $this->carPropertyCollection;
}
}

View File

@ -0,0 +1,121 @@
<?php
namespace App\Domain\Search;
use App\Domain\AI\AIClient;
use App\Domain\Model\Embedding\Embedding;
use App\Domain\Repository\BrandRepository;
use App\Domain\Repository\CarModelRepository;
use App\Domain\Repository\CarPropertyRepository;
use App\Domain\Repository\CarRevisionRepository;
use App\Domain\Repository\EmbeddingRepository;
use App\Domain\Search\TileBuilder\AccelerationTileBuilder;
use App\Domain\Search\TileBuilder\TileBuilder;
use App\Domain\Search\TileBuilder\BatteryTileBuilder;
use App\Domain\Search\TileBuilder\BrandTileBuilder;
use App\Domain\Search\TileBuilder\PriceTileBuilder;
use App\Domain\Search\TileBuilder\SubSectionTileBuilder;
use App\Domain\Search\TileBuilder\SectionTileBuilder;
use App\Domain\Search\View\ViewProvider;
final class AiTileEngine implements Engine
{
public function __construct(
private readonly EmbeddingRepository $embeddingRepository,
private readonly CarPropertyRepository $carPropertyRepository,
private readonly CarModelRepository $carModelRepository,
private readonly CarRevisionRepository $carRevisionRepository,
private readonly BrandRepository $brandRepository,
private readonly AIClient $aiClient,
private readonly ViewProvider $viewProvider,
) {
}
public function search(string $query): TileCollection
{
$results = $this->embeddingRepository->searchByLargeEmbeddingVector($this->aiClient->embedTextLarge($query));
$carProperties = $this->carPropertyRepository->findByEmbeddingPhraseHashes(array_map(fn (Embedding $embedding) => $embedding->phraseHash(), $results->array()));
$carProperties = $carProperties->sortByRevision();
$contextString = "";
$currentRevisionId = null;
foreach ($carProperties->array() as $carProperty) {
$carRevision = $this->carRevisionRepository->findById($carProperty->carRevisionId);
if ($carRevision === null) {
continue;
}
$carModel = $this->carModelRepository->findByCarRevision($carRevision);
if ($carModel === null) {
continue;
}
$brand = $this->brandRepository->findByCarModel($carModel);
if ($brand === null) {
continue;
}
if ($currentRevisionId !== $carProperty->carRevisionId->value) {
$currentRevisionId = $carProperty->carRevisionId->value;
$contextString .= "--------------------------------\n";
$contextString .= "Model: " . $carModel->name . " (CarModelId: " . $carModel->carModelId->value . ")\n";
$contextString .= "Brand: " . $brand->name . " (BrandId: " . $brand->brandId->value . ")\n";
$contextString .= "Revision: " . $carRevision->name . " (CarRevisionId: " . $carRevision->carRevisionId->value . ")\n";
}
$contextString .= $carProperty->value->humanReadable() . " (CarPropertyId: " . $carProperty->carPropertyId->value . ")\n";
}
$views = $this->viewProvider->getAllViews();
$viewString = "";
foreach ($views as $view) {
$reflectionClass = new \ReflectionClass($view);
$shortClassName = $reflectionClass->getShortName();
$viewString .= "--------------------------------\n";
$viewString .= "View: " . $shortClassName . "\n";
$viewString .= "Description: " . $view->description() . "\n";
$viewString .= "Data description: " . json_encode($view->dataDescription()) . "\n";
}
$input = <<<EOF
You are a powerful search engine that can answer questions about electric cars.
You are given a list of car properties that could be relevant to the question:
$contextString
The search query is:
```
$query
```
You have to decide which view to use and which data to return.
You can choose from the following views:
$viewString
View example:
```json
{
"view": "ExampleView",
"data": {
"foo": "bar",
}
}
```
EOF;
$response = $this->aiClient->generateJson($input);
if (!isset($response['view']) || !is_string($response['view'])) {
throw new \Exception('Invalid JSON response from AI');
}
$view = $this->viewProvider->getView($response['view']);
$data = $response['data'] ?? [];
if (!is_array($data)) {
throw new \Exception('Invalid JSON response from AI');
}
return $view->build($data);
}
}

View File

@ -2,27 +2,9 @@
namespace App\Domain\Search; namespace App\Domain\Search;
use App\Domain\AI\AIClient; use App\Domain\Search\TileCollection;
use App\Domain\Model\Embedding\Embedding;
use App\Domain\Repository\CarPropertyRepository;
use App\Domain\Repository\EmbeddingRepository;
class Engine interface Engine
{ {
public function __construct( public function search(string $query): TileCollection;
private readonly EmbeddingRepository $embeddingRepository,
private readonly CarPropertyRepository $carPropertyRepository,
private readonly AIClient $aiClient,
private readonly TileBuilder $tileBuilder,
) {
}
public function search(string $query): TileCollection
{
$results = $this->embeddingRepository->searchByLargeEmbeddingVector($this->aiClient->embedTextLarge($query));
$carProperties = $this->carPropertyRepository->findByEmbeddingPhraseHashes(array_map(fn (Embedding $embedding) => $embedding->phraseHash(), $results->array()));
return $this->tileBuilder->build($carProperties);
}
} }

View File

@ -1,58 +0,0 @@
<?php
namespace App\Domain\Search;
use App\Domain\Model\CarPropertyCollection;
use App\Domain\Model\Id\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,23 @@
<?php
namespace App\Domain\Search\TileBuilder;
use App\Domain\Model\Cars\Brand;
use App\Domain\Model\Cars\CarModel;
use App\Domain\Model\Cars\CarProperty;
use App\Domain\Model\Cars\CarRevision;
use App\Domain\Model\Cars\CarPropertyValues\V1\Acceleration;
use App\Domain\Search\TileCollection;
use App\Domain\Search\Tiles\AccelerationTile;
final readonly class AccelerationTileBuilder implements TileBuilder
{
public function build(CarProperty $carProperty): ?TileCollection
{
if (!$carProperty->value instanceof Acceleration) {
return null;
}
return new TileCollection([new AccelerationTile($carProperty->value)]);
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Domain\Search\TileBuilder;
use App\Domain\Model\Cars\Brand;
use App\Domain\Model\Cars\CarModel;
use App\Domain\Model\Cars\CarProperty;
use App\Domain\Model\Cars\CarPropertyValues\V1\Production;
use App\Domain\Model\Cars\CarRevision;
use App\Domain\Search\TileCollection;
use App\Domain\Search\Tiles\AvailabilityTile;
final readonly class AvailabilityTileBuilder implements TileBuilder
{
public function build(CarProperty $carProperty): ?TileCollection
{
if ($carProperty->value instanceof Production) {
return new TileCollection([new AvailabilityTile(
$carProperty->value->productionBegin,
$carProperty->value->productionEnd,
)]);
}
return null;
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Domain\Search\TileBuilder;
use App\Domain\Model\Battery\BatteryProperties;
use App\Domain\Model\Cars\Brand;
use App\Domain\Model\Cars\CarModel;
use App\Domain\Model\Cars\CarProperty;
use App\Domain\Model\Cars\CarPropertyValues\V1\Battery\BatteryType;
use App\Domain\Model\Cars\CarRevision;
use App\Domain\Search\TileCollection;
use App\Domain\Search\Tiles\BatteryTile;
final readonly class BatteryTileBuilder implements TileBuilder
{
public function build(CarProperty $carProperty): ?TileCollection
{
if ($carProperty->value instanceof BatteryType) {
return new TileCollection([new BatteryTile(new BatteryProperties(
$carProperty->value->chemistry,
$carProperty->value->model,
$carProperty->value->manufacturer,
))]);
}
return null;
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Domain\Search\TileBuilder;
use App\Domain\Model\Cars\Brand;
use App\Domain\Model\Cars\CarModel;
use App\Domain\Model\Cars\CarProperty;
use App\Domain\Model\Cars\CarRevision;
use App\Domain\Model\Cars\CarPropertyValues\V1\Charging\ChargeTimeProperties;
use App\Domain\Search\Tiles\ChargingTile;
use App\Domain\Search\TileCollection;
final readonly class ChargingTileBuilder implements TileBuilder
{
public function build(CarProperty $carProperty): ?TileCollection
{
if ($carProperty->value instanceof ChargingProperties) {
return new TileCollection([new ChargingTile($carProperty->value)]);
}
return null;
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Domain\Search\TileBuilder;
use App\Domain\Model\Cars\Brand;
use App\Domain\Model\Cars\CarModel;
use App\Domain\Model\Cars\CarProperty;
use App\Domain\Model\Cars\CarRevision;
use App\Domain\Search\TileCollection;
use App\Domain\Search\Tiles\ConsumptionTile;
final readonly class ConsumptionTileBuilder implements TileBuilder
{
public function build(CarProperty $carProperty): ?TileCollection
{
// Implementation would need to extract consumption data from the CarProperty
// This is a placeholder - you'll need to implement the actual logic
// based on how CarProperty contains consumption information
return null;
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Domain\Search\TileBuilder;
use App\Domain\Model\Cars\Brand;
use App\Domain\Model\Cars\CarModel;
use App\Domain\Model\Cars\CarProperty;
use App\Domain\Model\Cars\CarRevision;
use App\Domain\Search\TileCollection;
final readonly class DrivetrainTileBuilder implements TileBuilder
{
public function build(CarProperty $carProperty): ?TileCollection
{
// Implementation would need to extract drivetrain data from the CarProperty
// This is a placeholder - you'll need to implement the actual logic
// based on how CarProperty contains drivetrain information
return null;
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Domain\Search\TileBuilder;
use App\Domain\Model\Cars\Brand;
use App\Domain\Model\Cars\CarModel;
use App\Domain\Model\Cars\CarProperty;
use App\Domain\Model\Cars\CarRevision;
use App\Domain\Search\TileCollection;
final readonly class PowerTileBuilder implements TileBuilder
{
public function build(CarProperty $carProperty): ?TileCollection
{
// Implementation would need to extract power data from the CarProperty
// This is a placeholder - you'll need to implement the actual logic
// based on how CarProperty contains power information
return null;
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Domain\Search\TileBuilder;
use App\Domain\Model\Cars\Brand;
use App\Domain\Model\Cars\CarModel;
use App\Domain\Model\Cars\CarProperty;
use App\Domain\Model\Cars\CarRevision;
use App\Domain\Model\Cars\CarPropertyValues\V1\CatalogPrice;
use App\Domain\Search\TileCollection;
use App\Domain\Search\Tiles\PriceTile;
final readonly class PriceTileBuilder implements TileBuilder
{
public function build(CarProperty $carProperty): ?TileCollection
{
if (!$carProperty->value instanceof CatalogPrice) {
return null;
}
return new TileCollection([new PriceTile($carProperty->value->price)]);
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Domain\Search\TileBuilder;
use App\Domain\Model\Cars\Brand;
use App\Domain\Model\Cars\CarModel;
use App\Domain\Model\Cars\CarProperty;
use App\Domain\Model\Cars\CarRevision;
use App\Domain\Model\Value\Date;
use App\Domain\Search\TileCollection;
use App\Domain\Search\Tiles\ProductionPeriodTile;
final readonly class ProductionPeriodTileBuilder implements TileBuilder
{
/**
* @param Brand $brand
* @param CarModel $carModel
* @param CarRevision $carRevision
* @param CarProperty $carProperty
*
* @return ProductionPeriodTile|null
*/
public function build(CarProperty $carProperty): ?TileCollection
{
// Implementation would need to extract production period data from the CarProperty
// This is a placeholder - you'll need to implement the actual logic
// based on how CarProperty contains production period information
return null;
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Domain\Search\TileBuilder;
use App\Domain\Model\Cars\CarProperty;
use App\Domain\Model\Cars\CarPropertyValues\V1\RangeSpecification;
use App\Domain\Search\TileCollection;
use App\Domain\Search\Tiles\RangeTile;
final readonly class RangeTileBuilder implements TileBuilder
{
public function build(CarProperty $carProperty): ?TileCollection
{
if ($carProperty->value instanceof RangeSpecification) {
return new TileCollection([new RangeTile($carProperty->value->nefzRange->range)]);
}
return null;
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Domain\Search\TileBuilder;
use App\Domain\Model\Cars\CarProperty;
use App\Domain\Model\Cars\CarPropertyValues\V1\CarPropertyValue;
use App\Domain\Search\TileCollection;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
#[AutoconfigureTag('app.tile_builder')]
interface TileBuilder
{
/**
* @param CarProperty<CarPropertyValue> $carProperty
*
* @return TileCollection|null
*/
public function build(CarProperty $carProperty): ?TileCollection;
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Domain\Search\TileBuilder;
use App\Domain\Model\Cars\CarProperty;
use App\Domain\Model\Cars\CarPropertyValues\V1\CarPropertyValue;
use App\Domain\Search\TileCollection;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
final readonly class TileBuilderProvider
{
/**
* @param iterable<TileBuilder> $tileBuilders
*/
public function __construct(
#[AutowireIterator('app.tile_builder')]
private iterable $tileBuilders,
) {}
/**
* @param CarProperty<CarPropertyValue> $carProperty
*/
public function build(CarProperty $carProperty): TileCollection
{
foreach ($this->tileBuilders as $tileBuilder) {
$tile = $tileBuilder->build($carProperty);
if ($tile !== null) {
return $tile;
}
}
throw new \Exception(sprintf('No tile builder found for car property %s of type %s', $carProperty->carPropertyId->value, get_class($carProperty->value)));
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Domain\Search\TileBuilder;
use App\Domain\Model\Cars\CarProperty;
use App\Domain\Model\Cars\CarPropertyValues\V1\TopSpeed;
use App\Domain\Search\TileCollection;
use App\Domain\Search\Tiles\TopSpeedTile;
final readonly class TopSpeedTileBuilder implements TileBuilder
{
public function build(CarProperty $carProperty): ?TileCollection
{
if ($carProperty->value instanceof TopSpeed) {
return new TileCollection([new TopSpeedTile($carProperty->value->speed)]);
}
return null;
}
}

View File

@ -1,61 +0,0 @@
<?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::DRIVING_CHARACTERISTICS_ACCELERATION,
])) {
$priceProperty = $carProperties->getOne(CarPropertyType::CATALOG_PRICE);
$accelerationProperty = $carProperties->getOne(CarPropertyType::DRIVING_CHARACTERISTICS_ACCELERATION);
if ($priceProperty !== null && $accelerationProperty !== null && $carRevision->image !== 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(
$carRevision->image,
[
new PriceTile($priceValue),
new AccelerationTile($accelerationValue),
]
));
}
}
$tiles->add(new SectionTile(
$carRevision->name,
$subTiles->array()
));
}
}

View File

@ -23,4 +23,9 @@ final class TileCollection
{ {
$this->tiles[] = $tile; $this->tiles[] = $tile;
} }
public function merge(TileCollection $tileCollection): void
{
$this->tiles = array_merge($this->tiles, $tileCollection->array());
}
} }

View File

@ -2,7 +2,7 @@
namespace App\Domain\Search\Tiles; namespace App\Domain\Search\Tiles;
use App\Domain\Model\Value\Acceleration; use App\Domain\Model\Cars\CarPropertyValues\V1\Acceleration;
class AccelerationTile class AccelerationTile
{ {

View File

@ -7,7 +7,7 @@ use App\Domain\Model\Value\Date;
class AvailabilityTile class AvailabilityTile
{ {
public function __construct( public function __construct(
public readonly string $status,
public readonly ?Date $availableSince = null, public readonly ?Date $availableSince = null,
public readonly ?Date $availableUntil = null,
) {} ) {}
} }

View File

@ -10,7 +10,7 @@ final readonly class CarTile
* @param object[] $tiles * @param object[] $tiles
*/ */
public function __construct( public function __construct(
public Image $image, public ?Image $image,
public array $tiles public array $tiles
) { } ) { }
} }

View File

@ -2,7 +2,7 @@
namespace App\Domain\Search\Tiles; namespace App\Domain\Search\Tiles;
use App\Domain\Model\Value\ChargingSpeed; use App\Domain\Model\Cars\CarPropertyValues\V1\Charging\ChargingSpeed;
class ChargingTile class ChargingTile
{ {

View File

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

View File

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

View File

@ -0,0 +1,31 @@
<?php
namespace App\Domain\Search\View;
use App\Domain\Search\TileCollection;
final readonly class CarRevisionComparison implements View
{
/**
* @param array<mixed> $data
*
* @return TileCollection
*/
public function build(array $data): TileCollection
{
return new TileCollection([]);
}
public function dataDescription(): array
{
return [
'car_revision_id_1' => 'Car revision ID 1',
'car_revision_id_2' => 'Car revision ID 2',
];
}
public function description(): string
{
return 'This view shows a comparison of two car revisions.';
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Domain\Search\View;
use App\Domain\Search\TileCollection;
final readonly class FullBrandView implements View
{
/**
* @param array<mixed> $data
*
* @return TileCollection
*/
public function build(array $data): TileCollection
{
return new TileCollection([]);
}
public function dataDescription(): array
{
return [
'brand_id' => 'Brand ID',
];
}
public function description(): string
{
return <<<'EOT'
This view shows all information about a brand. It is used to display the full information about a brand if requested in the query.
E.g. a brand name is given. You should only use this view if you are really sure, that the query is about a brand and not models or revisions.
EOT;
}
}

View File

@ -0,0 +1,93 @@
<?php
namespace App\Domain\Search\View;
use App\Domain\Model\Cars\CarPropertyValues\V1\Acceleration;
use App\Domain\Model\Cars\CarPropertyValues\V1\Production;
use App\Domain\Model\Cars\CarPropertyValues\V1\RangeSpecification;
use App\Domain\Model\Cars\CarPropertyValues\V1\TopSpeed;
use App\Domain\Model\Cars\CarPropertyValues\V1\CarPropertyValue;
use App\Domain\Model\Cars\CarProperty;
use App\Domain\Model\Id\CarModelId;
use App\Domain\Model\Id\CarRevisionId;
use App\Domain\Repository\CarModelRepository;
use App\Domain\Repository\Loader\FullCarLoader;
use App\Domain\Repository\Loader\FullCarRevisionLoader;
use App\Domain\Search\TileCollection;
use App\Domain\Search\TileBuilder\TileBuilderProvider;
use App\Domain\Search\Tiles\CarTile;
use App\Domain\Search\Tiles\SectionTile;
use App\Domain\Search\Tiles\SubSectionTile;
final readonly class FullCarModelView implements View
{
public function __construct(
private readonly FullCarLoader $fullCarLoader,
private readonly TileBuilderProvider $tileBuilderProvider,
) {}
/**
* @param array<mixed> $data
*
* @return TileCollection
*/
public function build(array $data): TileCollection
{
if (!is_string($data['car_model_id'] ?? null)) {
throw new \InvalidArgumentException('Car model ID is required');
}
$fullCarModel = $this->fullCarLoader->loadModel(new CarModelId($data['car_model_id']));
$carModel = $fullCarModel->getCarModel();
$brand = $fullCarModel->getBrand();
$carRevisions = $fullCarModel->getCarRevisions();
$allTiles = [];
// Add section title for the car model
$allTiles[] = new SectionTile($brand->name . ' ' . $carModel->name);
// Generate a CarTile for each car revision
foreach ($carRevisions as $fullCarRevision) {
$carProperties = $fullCarRevision->getCarPropertyCollection();
$carRevision = $fullCarRevision->getCarRevision();
/** @var CarProperty<CarPropertyValue>[] $properties */
$properties = array_filter([
$carProperties->getOne(Production::class),
$carProperties->getOne(TopSpeed::class),
$carProperties->getOne(Acceleration::class),
$carProperties->getOne(RangeSpecification::class),
], static fn($value) => $value !== null);
$tiles = new TileCollection([]);
foreach ($properties as $property) {
$tileCollection = $this->tileBuilderProvider->build($property);
$tiles->merge($tileCollection);
}
$allTiles[] = new SubSectionTile($brand->name . ' ' . $carModel->name . ' ' . $carRevision->name);
$allTiles[] = new CarTile(
$carRevision->image,
$tiles->array(),
);
}
return new TileCollection($allTiles);
}
public function dataDescription(): array
{
return [
'car_model_id' => 'Car model ID',
];
}
public function description(): string
{
return <<<'EOT'
This view shows all information about a car model. It is used to display the full information about a car model if requested by the user.
E.g. a model name is given
EOT;
}
}

View File

@ -0,0 +1,81 @@
<?php
namespace App\Domain\Search\View;
use App\Domain\Model\Cars\CarPropertyValues\V1\Acceleration;
use App\Domain\Model\Cars\CarPropertyValues\V1\Production;
use App\Domain\Model\Cars\CarPropertyValues\V1\RangeSpecification;
use App\Domain\Model\Cars\CarPropertyValues\V1\TopSpeed;
use App\Domain\Model\Cars\CarPropertyValues\V1\CarPropertyValue;
use App\Domain\Model\Cars\CarProperty;
use App\Domain\Model\Id\CarRevisionId;
use App\Domain\Repository\Loader\FullCarLoader;
use App\Domain\Repository\Loader\FullCarRevisionLoader;
use App\Domain\Search\TileBuilder\TileBuilderProvider;
use App\Domain\Search\TileCollection;
use App\Domain\Search\Tiles\CarTile;
use App\Domain\Search\Tiles\SectionTile;
final readonly class FullCarRevisionView implements View
{
public function __construct(
private readonly FullCarLoader $fullCarLoader,
private readonly TileBuilderProvider $tileBuilderProvider
) {}
/**
* @param array<mixed> $data
*
* @return TileCollection
*/
public function build(array $data): TileCollection
{
if (!is_string($data['car_revision_id'] ?? null)) {
throw new \InvalidArgumentException('Car revision ID is required');
}
$fullCar = $this->fullCarLoader->loadRevision(new CarRevisionId($data['car_revision_id']));
$carRevision = $fullCar->getCarRevision();
$carModel = $fullCar->getCarModel();
$brand = $fullCar->getBrand();
$carProperties = $fullCar->getCarPropertyCollection();
/** @var CarProperty<CarPropertyValue>[] $properties */
$properties = array_filter([
$carProperties->getOne(Production::class),
$carProperties->getOne(TopSpeed::class),
$carProperties->getOne(Acceleration::class),
$carProperties->getOne(RangeSpecification::class),
], static fn($value) => $value !== null);
$tiles = new TileCollection([]);
foreach ($properties as $property) {
$tileCollection = $this->tileBuilderProvider->build($property);
$tiles->merge($tileCollection);
}
return new TileCollection([
new SectionTile($brand->name . ' ' . $carModel->name . ' ' . $carRevision->name),
new CarTile(
$carRevision->image,
$tiles->array(),
),
]);
}
public function dataDescription(): array
{
return [
'car_revision_id' => 'Car revision ID',
];
}
public function description(): string
{
return <<<'EOT'
This view shows all information about a car revision. It is used to display the full information about a car revision if a specific revision is requested in the query.
E.g. a model and revision name is given. You should always prefer this view over the full car model view if enough information is given in the query.
EOT;
}
}

View File

@ -0,0 +1,72 @@
<?php
namespace App\Domain\Search\View;
use App\Domain\Model\Id\CarPropertyId;
use App\Domain\Model\Id\CarRevisionId;
use App\Domain\Repository\BrandRepository;
use App\Domain\Repository\CarModelRepository;
use App\Domain\Repository\CarPropertyRepository;
use App\Domain\Repository\CarRevisionRepository;
use App\Domain\Repository\Loader\FullCarLoader;
use App\Domain\Search\TileBuilder\TileBuilderProvider;
use App\Domain\Search\TileCollection;
use App\Domain\Search\Tiles\SectionTile;
final readonly class SpecificCarPropertyView implements View
{
public function __construct(
private readonly TileBuilderProvider $tileBuilderProvider,
private readonly FullCarLoader $fullCarLoader,
private readonly CarPropertyRepository $carPropertyRepository,
) {}
/**
* @param array<mixed> $data
*
* @return TileCollection
*/
public function build(array $data): TileCollection
{
$properties = $data['properties'] ?? [];
if (!is_array($properties)) {
throw new \Exception('Properties must be an array');
}
$tiles = [];
foreach ($properties as $propertyId) {
if (!is_string($propertyId)) {
continue;
}
$carProperty = $this->carPropertyRepository->findById(new CarPropertyId($propertyId));
if ($carProperty === null) {
continue;
}
$fullCar = $this->fullCarLoader->loadRevisionByCarPropertyId($carProperty->carPropertyId);
$tileCollection = $this->tileBuilderProvider->build($carProperty);
$tiles[] = new SectionTile($fullCar->getBrand()->name . ' ' . $fullCar->getCarModel()->name . ' ' . $fullCar->getCarRevision()->name);
$tiles = array_merge($tiles, $tileCollection->array());
}
return new TileCollection($tiles);
}
public function dataDescription(): array
{
return [
'properties' => [
'carproperty_123',
'carproperty_456',
],
];
}
public function description(): string
{
return 'This view shows all information about a specific car property.';
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Domain\Search\View;
use App\Domain\Search\TileCollection;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
#[AutoconfigureTag('app.view')]
interface View
{
/**
* @param array<mixed> $data
*
* @return TileCollection
*/
public function build(array $data): TileCollection;
/**
* @return array<string, mixed>
*/
public function dataDescription(): array;
/**
* @return string
*/
public function description(): string;
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Domain\Search\View;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
final readonly class ViewProvider
{
/**
* @param iterable<View> $views
*/
public function __construct(
#[AutowireIterator('app.view')]
private iterable $views,
) {}
/**
* @param string $viewClass
*
* @return View
*/
public function getView(string $viewClass): View
{
foreach ($this->views as $view) {
$reflectionClass = new \ReflectionClass($view);
$shortClassName = $reflectionClass->getShortName();
if ($shortClassName === $viewClass) {
return $view;
}
}
throw new \Exception(sprintf('View %s not found', $viewClass));
}
/**
* @return array<View>
*/
public function getAllViews(): array
{
return iterator_to_array($this->views);
}
}

View File

@ -2,7 +2,7 @@
namespace App\Infrastructure\PostgreSQL\Repository\BrandRepository; namespace App\Infrastructure\PostgreSQL\Repository\BrandRepository;
use App\Domain\Model\Brand; use App\Domain\Model\Cars\Brand;
use App\Domain\Model\Id\BrandId; use App\Domain\Model\Id\BrandId;
class ModelMapper class ModelMapper

View File

@ -2,9 +2,9 @@
namespace App\Infrastructure\PostgreSQL\Repository\BrandRepository; namespace App\Infrastructure\PostgreSQL\Repository\BrandRepository;
use App\Domain\Model\Brand; use App\Domain\Model\Cars\Brand;
use App\Domain\Model\BrandCollection; use App\Domain\Model\Cars\BrandCollection;
use App\Domain\Model\CarModel; use App\Domain\Model\Cars\CarModel;
use App\Domain\Model\Id\BrandId; use App\Domain\Model\Id\BrandId;
use App\Domain\Repository\BrandRepository; use App\Domain\Repository\BrandRepository;
use Doctrine\DBAL\Connection; use Doctrine\DBAL\Connection;

View File

@ -2,8 +2,8 @@
namespace App\Infrastructure\PostgreSQL\Repository\CarModelRepository; namespace App\Infrastructure\PostgreSQL\Repository\CarModelRepository;
use App\Domain\Model\CarModel; use App\Domain\Model\Cars\CarModel;
use App\Domain\Model\Brand; use App\Domain\Model\Cars\Brand;
use App\Domain\Model\Id\BrandId; use App\Domain\Model\Id\BrandId;
use App\Domain\Model\Id\CarModelId; use App\Domain\Model\Id\CarModelId;

View File

@ -2,9 +2,9 @@
namespace App\Infrastructure\PostgreSQL\Repository\CarModelRepository; namespace App\Infrastructure\PostgreSQL\Repository\CarModelRepository;
use App\Domain\Model\CarModel; use App\Domain\Model\Cars\CarModel;
use App\Domain\Model\CarModelCollection; use App\Domain\Model\Cars\CarModelCollection;
use App\Domain\Model\CarRevision; use App\Domain\Model\Cars\CarRevision;
use App\Domain\Model\Id\CarModelId; use App\Domain\Model\Id\CarModelId;
use App\Domain\Model\Id\BrandId; use App\Domain\Model\Id\BrandId;
use App\Domain\Repository\CarModelRepository; use App\Domain\Repository\CarModelRepository;

View File

@ -2,10 +2,10 @@
namespace App\Infrastructure\PostgreSQL\Repository\CarPropertyRepository; namespace App\Infrastructure\PostgreSQL\Repository\CarPropertyRepository;
use App\Domain\Model\CarProperty; use App\Domain\Model\Cars\CarPropertyCollection;
use App\Domain\Model\CarPropertyCollection; use App\Domain\Model\Cars\CarProperty;
use App\Domain\Model\CarPropertyType; use App\Domain\Model\Cars\CarPropertyValues\V1\CarPropertyValue;
use App\Domain\Model\CarRevision; use App\Domain\Model\Cars\CarRevision;
use App\Domain\Model\Id\CarPropertyId; use App\Domain\Model\Id\CarPropertyId;
use App\Domain\Model\Id\CarRevisionId; use App\Domain\Model\Id\CarRevisionId;
use App\Domain\Repository\CarPropertyRepository; use App\Domain\Repository\CarPropertyRepository;
@ -17,6 +17,21 @@ final class SqlCarPropertyRepository implements CarPropertyRepository
private readonly Connection $connection, private readonly Connection $connection,
) {} ) {}
public function findById(CarPropertyId $carPropertyId): ?CarProperty
{
$result = $this->connection->executeQuery(
'SELECT * FROM car_properties WHERE id = ?',
[$carPropertyId->value]
);
$row = $result->fetchAssociative();
if ($row === false) {
return null;
}
return $this->mapRowToCarProperty($row);
}
public function findByCarRevision(CarRevision $carRevision): CarPropertyCollection public function findByCarRevision(CarRevision $carRevision): CarPropertyCollection
{ {
$result = $this->connection->executeQuery( $result = $this->connection->executeQuery(
@ -37,8 +52,20 @@ final class SqlCarPropertyRepository implements CarPropertyRepository
public function findByEmbeddingIds(array $embeddingIds): CarPropertyCollection public function findByEmbeddingIds(array $embeddingIds): CarPropertyCollection
{ {
// Placeholder implementation - would need to join with embeddings table $result = $this->connection->executeQuery(
return new CarPropertyCollection([]); 'SELECT * FROM car_properties WHERE id IN (?)',
[$embeddingIds]
);
$carProperties = [];
foreach ($result->fetchAllAssociative() as $row) {
$carProperty = $this->mapRowToCarProperty($row);
if ($carProperty !== null) {
$carProperties[] = $carProperty;
}
}
return new CarPropertyCollection($carProperties);
} }
public function findByEmbeddingPhraseHashes(array $phraseHashes): CarPropertyCollection public function findByEmbeddingPhraseHashes(array $phraseHashes): CarPropertyCollection
@ -71,11 +98,10 @@ final class SqlCarPropertyRepository implements CarPropertyRepository
{ {
$this->connection->transactional(function (Connection $connection) use ($carProperty) { $this->connection->transactional(function (Connection $connection) use ($carProperty) {
$connection->executeStatement( $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', 'INSERT INTO car_properties (id, car_revision_id, value) VALUES (?, ?, ?) ON CONFLICT (id) DO UPDATE SET car_revision_id = EXCLUDED.car_revision_id, value = EXCLUDED.value',
[ [
$carProperty->carPropertyId->value, $carProperty->carPropertyId->value,
$carProperty->carRevisionId->value, $carProperty->carRevisionId->value,
$carProperty->type->value,
serialize($carProperty->value), serialize($carProperty->value),
] ]
); );
@ -115,25 +141,27 @@ final class SqlCarPropertyRepository implements CarPropertyRepository
*/ */
private function mapRowToCarProperty(array $row): ?CarProperty private function mapRowToCarProperty(array $row): ?CarProperty
{ {
try { $id = $row['id'] ?? null;
$id = $row['id'] ?? null; $carRevisionId = $row['car_revision_id'] ?? null;
$carRevisionId = $row['car_revision_id'] ?? null; $value = $row['value'] ?? null;
$type = $row['type'] ?? null;
$value = $row['value'] ?? null;
if (!is_string($id) || !is_string($carRevisionId) || !is_string($type) || !is_string($value)) { if (!is_string($id) || !is_string($carRevisionId) || !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; return null;
} }
try {
$value = unserialize($value);
if (!$value instanceof CarPropertyValue) {
return null;
}
} catch (\Exception $e) {
return null;
}
return new CarProperty(
new CarPropertyId($id),
new CarRevisionId($carRevisionId),
$value
);
} }
} }

View File

@ -2,7 +2,7 @@
namespace App\Infrastructure\PostgreSQL\Repository\CarRevisionRepository; namespace App\Infrastructure\PostgreSQL\Repository\CarRevisionRepository;
use App\Domain\Model\CarRevision; use App\Domain\Model\Cars\CarRevision;
use App\Domain\Model\Id\CarModelId; use App\Domain\Model\Id\CarModelId;
use App\Domain\Model\Id\CarRevisionId; use App\Domain\Model\Id\CarRevisionId;
use App\Domain\Model\Image; use App\Domain\Model\Image;

View File

@ -2,8 +2,8 @@
namespace App\Infrastructure\PostgreSQL\Repository\CarRevisionRepository; namespace App\Infrastructure\PostgreSQL\Repository\CarRevisionRepository;
use App\Domain\Model\CarRevision; use App\Domain\Model\Cars\CarRevision;
use App\Domain\Model\CarRevisionCollection; use App\Domain\Model\Cars\CarRevisionCollection;
use App\Domain\Model\Id\CarRevisionId; use App\Domain\Model\Id\CarRevisionId;
use App\Domain\Model\Id\CarModelId; use App\Domain\Model\Id\CarModelId;
use App\Domain\Repository\CarRevisionRepository; use App\Domain\Repository\CarRevisionRepository;

View File

@ -66,7 +66,7 @@ final class SqlEmbeddingRepository implements EmbeddingRepository
return $this->mapRowToEmbedding($row); return $this->mapRowToEmbedding($row);
} }
public function searchByLargeEmbeddingVector(LargeEmbeddingVector $embeddingVector, int $limit = 100): EmbeddingCollection public function searchByLargeEmbeddingVector(LargeEmbeddingVector $embeddingVector, int $limit = 20): EmbeddingCollection
{ {
$result = $this->connection->executeQuery( $result = $this->connection->executeQuery(
'SELECT *, large_embedding_vector <=> :embeddingVector AS distance 'SELECT *, large_embedding_vector <=> :embeddingVector AS distance
@ -82,15 +82,13 @@ final class SqlEmbeddingRepository implements EmbeddingRepository
$embeddings = []; $embeddings = [];
foreach ($result->fetchAllAssociative() as $row) { foreach ($result->fetchAllAssociative() as $row) {
if ($row['distance'] < 0.7) { $embeddings[] = $this->mapRowToEmbedding($row);
$embeddings[] = $this->mapRowToEmbedding($row);
}
} }
return new EmbeddingCollection($embeddings); return new EmbeddingCollection($embeddings);
} }
public function searchBySmallEmbeddingVector(SmallEmbeddingVector $smallEmbeddingVector, int $limit = 10): EmbeddingCollection public function searchBySmallEmbeddingVector(SmallEmbeddingVector $smallEmbeddingVector, int $limit = 20): EmbeddingCollection
{ {
$result = $this->connection->executeQuery( $result = $this->connection->executeQuery(
'SELECT * 'SELECT *

View File

@ -173,5 +173,18 @@
"files": [ "files": [
"config/packages/validator.yaml" "config/packages/validator.yaml"
] ]
},
"symfony/web-profiler-bundle": {
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.3",
"ref": "5b2b543e13942495c0003f67780cb4448af9e606"
},
"files": [
"config/packages/web_profiler.yaml",
"config/routes/web_profiler.yaml"
]
} }
} }

View File

@ -230,7 +230,7 @@
.subsection-title { .subsection-title {
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 600; font-weight: 600;
padding: 0.25rem 0.5rem; padding: 0.25rem 0rem;
position: relative; position: relative;
border-radius: 2px 2px 0 0; border-radius: 2px 2px 0 0;
border-bottom: none; border-bottom: none;

View File

@ -0,0 +1,39 @@
{# templates/Collector/ai_chat.html.twig #}
{% extends '@WebProfiler/Profiler/layout.html.twig' %}
{% block toolbar %}
{# Optional: Add a toolbar icon or summary here #}
{% endblock %}
{% block menu %}
<span class="label">
<span class="icon">{{ include('@WebProfiler/Icon/logger.svg') }}</span>
<strong>AI Chat</strong>
</span>
{% endblock %}
{% block panel %}
<h2>AI Chat Log</h2>
{% if collector.log is empty %}
<div class="empty">
<p>No AI chat log entries.</p>
</div>
{% else %}
<table>
<thead>
<tr>
<th>Prompt</th>
<th>Response</th>
</tr>
</thead>
<tbody>
{% for entry in collector.log %}
<tr>
<td><pre>{{ entry.prompt|e }}</pre></td>
<td><pre>{{ entry.response|e }}</pre></td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endblock %}

View File

@ -1,6 +1,10 @@
<div style="grid-column: span 2; grid-row: span 2"> <div style="grid-column: span 2; grid-row: span 2">
{% if tile.image %} {% if tile.image %}
<img src="{{ tile.image.externalPublicUrl }}" style="width: 100%; height: 100%; object-fit: cover;"> <img src="{{ tile.image.externalPublicUrl }}" style="width: 100%; height: 100%; object-fit: cover;">
{% else %}
<div style="width: 100%; height: 100%; background-color: #f0f0f0; display: flex; align-items: center; justify-content: center; color: #666;">
<i class="fas fa-car" style="font-size: 3rem;"></i>
</div>
{% endif %} {% endif %}
</div> </div>
{% for tile in tile.tiles %} {% for tile in tile.tiles %}

View File

@ -1,41 +1,24 @@
<div class="tile production-period-tile"> <div class="tile production-period-tile">
<div class="tile-title"> <div class="tile-title">
<i class="fas fa-industry" style="color: #6c757d; margin-right: 8px;"></i>
Produktionszeitraum
</div>
<div class="production-timeline" style="margin-top: 8px;">
{% if tile.productionBegin or tile.productionEnd %} {% if tile.productionBegin or tile.productionEnd %}
<div style="display: flex; align-items: center; gap: 8px;"> <div style="font-size: 18px; font-weight: bold; margin-bottom: 4px;">
{% if tile.productionBegin %} {% if tile.productionBegin %}
<div style="text-align: center;"> {{ tile.productionBegin.year }}
<div style="font-weight: bold; font-size: 16px; color: #28a745;">{{ tile.productionBegin.year }}</div> {% else %}
<small style="color: #666;">Start</small> ?
{% endif %}
bis
{% if tile.productionEnd %}
{{ tile.productionEnd.year }}
{% else %}
heute
{% endif %}
</div> </div>
{% endif %}
{% if tile.productionBegin and tile.productionEnd %}
<div style="flex: 1; height: 2px; background: linear-gradient(to right, #28a745, #dc3545); margin: 0 4px;"></div>
{% elseif tile.productionBegin %}
<div style="flex: 1; height: 2px; background: linear-gradient(to right, #28a745, #007acc); margin: 0 4px;"></div>
{% endif %}
{% if tile.productionEnd %}
<div style="text-align: center;">
<div style="font-weight: bold; font-size: 16px; color: #dc3545;">{{ tile.productionEnd.year }}</div>
<small style="color: #666;">Ende</small>
</div>
{% elseif tile.productionBegin %}
<div style="text-align: center;">
<div style="font-weight: bold; font-size: 16px; color: #007acc;">laufend</div>
<small style="color: #666;">aktuell</small>
</div>
{% endif %}
</div>
{% else %} {% else %}
<div style="text-align: center; color: #666; font-style: italic;"> <div style="color: #666; font-style: italic;">
Zeitraum unbekannt Zeitraum unbekannt
</div> </div>
{% endif %} {% endif %}
</div> </div>
<small style="color: #666;">Verfügbarkeit</small> <small>Produktion</small>
</div> </div>

View File

@ -1,3 +1 @@
<h1 class="section-title" style="grid-column: span 5;">{{ tile.title }}</h1> <h1 class="section-title" style="grid-column: span 5;">{{ tile.title }}</h1>
{% include 'result/tiles/collection.html.twig' with { tiles: tile.tiles } %}

View File

@ -1,2 +1 @@
<h2 class="subsection-title" style="grid-column: span 5">{{ tile.title }}</h2> <h2 class="subsection-title" style="grid-column: span 5">{{ tile.title }}</h2>
{% include 'result/tiles/collection.html.twig' with { tiles: tile.tiles } %}