refactored

This commit is contained in:
Tim Lappe 2025-06-09 18:15:22 +02:00
parent 3f78e2e9f1
commit 7051bbf5b7
71 changed files with 1368 additions and 386 deletions

View File

@ -19,6 +19,7 @@
"symfony/dotenv": "7.2.*",
"symfony/flex": "^2",
"symfony/framework-bundle": "7.2.*",
"symfony/monolog-bundle": "^3.10",
"symfony/runtime": "7.2.*",
"symfony/serializer": "7.2.*",
"symfony/twig-bundle": "7.2.*",

438
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "e4da23c3811aae55314b0e018026605a",
"content-hash": "45fe64d8d59b093a4dc302486a3a607d",
"packages": [
{
"name": "composer/semver",
@ -792,6 +792,109 @@
},
"time": "2025-01-24T11:45:48+00:00"
},
{
"name": "monolog/monolog",
"version": "3.9.0",
"source": {
"type": "git",
"url": "https://github.com/Seldaek/monolog.git",
"reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Seldaek/monolog/zipball/10d85740180ecba7896c87e06a166e0c95a0e3b6",
"reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6",
"shasum": ""
},
"require": {
"php": ">=8.1",
"psr/log": "^2.0 || ^3.0"
},
"provide": {
"psr/log-implementation": "3.0.0"
},
"require-dev": {
"aws/aws-sdk-php": "^3.0",
"doctrine/couchdb": "~1.0@dev",
"elasticsearch/elasticsearch": "^7 || ^8",
"ext-json": "*",
"graylog2/gelf-php": "^1.4.2 || ^2.0",
"guzzlehttp/guzzle": "^7.4.5",
"guzzlehttp/psr7": "^2.2",
"mongodb/mongodb": "^1.8",
"php-amqplib/php-amqplib": "~2.4 || ^3",
"php-console/php-console": "^3.1.8",
"phpstan/phpstan": "^2",
"phpstan/phpstan-deprecation-rules": "^2",
"phpstan/phpstan-strict-rules": "^2",
"phpunit/phpunit": "^10.5.17 || ^11.0.7",
"predis/predis": "^1.1 || ^2",
"rollbar/rollbar": "^4.0",
"ruflin/elastica": "^7 || ^8",
"symfony/mailer": "^5.4 || ^6",
"symfony/mime": "^5.4 || ^6"
},
"suggest": {
"aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB",
"doctrine/couchdb": "Allow sending log messages to a CouchDB server",
"elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client",
"ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)",
"ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler",
"ext-mbstring": "Allow to work properly with unicode symbols",
"ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)",
"ext-openssl": "Required to send log messages using SSL",
"ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)",
"graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server",
"mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)",
"php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib",
"rollbar/rollbar": "Allow sending log messages to Rollbar",
"ruflin/elastica": "Allow sending log messages to an Elastic Search server"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Monolog\\": "src/Monolog"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "https://seld.be"
}
],
"description": "Sends your logs to files, sockets, inboxes, databases and various web services",
"homepage": "https://github.com/Seldaek/monolog",
"keywords": [
"log",
"logging",
"psr-3"
],
"support": {
"issues": "https://github.com/Seldaek/monolog/issues",
"source": "https://github.com/Seldaek/monolog/tree/3.9.0"
},
"funding": [
{
"url": "https://github.com/Seldaek",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/monolog/monolog",
"type": "tidelift"
}
],
"time": "2025-03-24T10:02:05+00:00"
},
{
"name": "nyholm/psr7",
"version": "1.8.2",
@ -3157,6 +3260,165 @@
],
"time": "2025-05-29T07:47:32+00:00"
},
{
"name": "symfony/monolog-bridge",
"version": "v7.3.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/monolog-bridge.git",
"reference": "1b188c8abbbef25b111da878797514b7a8d33990"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/1b188c8abbbef25b111da878797514b7a8d33990",
"reference": "1b188c8abbbef25b111da878797514b7a8d33990",
"shasum": ""
},
"require": {
"monolog/monolog": "^3",
"php": ">=8.2",
"symfony/http-kernel": "^6.4|^7.0",
"symfony/service-contracts": "^2.5|^3"
},
"conflict": {
"symfony/console": "<6.4",
"symfony/http-foundation": "<6.4",
"symfony/security-core": "<6.4"
},
"require-dev": {
"symfony/console": "^6.4|^7.0",
"symfony/http-client": "^6.4|^7.0",
"symfony/mailer": "^6.4|^7.0",
"symfony/messenger": "^6.4|^7.0",
"symfony/mime": "^6.4|^7.0",
"symfony/security-core": "^6.4|^7.0",
"symfony/var-dumper": "^6.4|^7.0"
},
"type": "symfony-bridge",
"autoload": {
"psr-4": {
"Symfony\\Bridge\\Monolog\\": ""
},
"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 integration for Monolog with various Symfony components",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/monolog-bridge/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-03-21T12:17:46+00:00"
},
{
"name": "symfony/monolog-bundle",
"version": "v3.10.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/monolog-bundle.git",
"reference": "414f951743f4aa1fd0f5bf6a0e9c16af3fe7f181"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/414f951743f4aa1fd0f5bf6a0e9c16af3fe7f181",
"reference": "414f951743f4aa1fd0f5bf6a0e9c16af3fe7f181",
"shasum": ""
},
"require": {
"monolog/monolog": "^1.25.1 || ^2.0 || ^3.0",
"php": ">=7.2.5",
"symfony/config": "^5.4 || ^6.0 || ^7.0",
"symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0",
"symfony/http-kernel": "^5.4 || ^6.0 || ^7.0",
"symfony/monolog-bridge": "^5.4 || ^6.0 || ^7.0"
},
"require-dev": {
"symfony/console": "^5.4 || ^6.0 || ^7.0",
"symfony/phpunit-bridge": "^6.3 || ^7.0",
"symfony/yaml": "^5.4 || ^6.0 || ^7.0"
},
"type": "symfony-bundle",
"extra": {
"branch-alias": {
"dev-master": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Bundle\\MonologBundle\\": ""
},
"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": "Symfony MonologBundle",
"homepage": "https://symfony.com",
"keywords": [
"log",
"logging"
],
"support": {
"issues": "https://github.com/symfony/monolog-bundle/issues",
"source": "https://github.com/symfony/monolog-bundle/tree/v3.10.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": "2023-11-06T17:08:13+00:00"
},
{
"name": "symfony/polyfill-ctype",
"version": "v1.32.0",
@ -4572,91 +4834,6 @@
],
"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",
"version": "v7.2.6",
@ -5314,11 +5491,96 @@
}
],
"time": "2025-04-17T09:11:12+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"
}
],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
"stability-flags": {},
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
@ -5328,6 +5590,6 @@
"ext-pdo": "*",
"ext-pgsql": "*"
},
"platform-dev": [],
"platform-dev": {},
"plugin-api-version": "2.6.0"
}

View File

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

View File

@ -0,0 +1,62 @@
monolog:
channels:
- deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists
when@dev:
monolog:
handlers:
main:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
channels: ["!event"]
# uncomment to get logging in your browser
# you may have to allow bigger header sizes in your Web server configuration
#firephp:
# type: firephp
# level: info
#chromephp:
# type: chromephp
# level: info
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine", "!console"]
when@test:
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [404, 405]
channels: ["!event"]
nested:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
when@prod:
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [404, 405]
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
nested:
type: stream
path: php://stderr
level: debug
formatter: monolog.formatter.json
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine"]
deprecation:
type: stream
channels: [deprecation]
path: php://stderr
formatter: monolog.formatter.json

View File

@ -0,0 +1,30 @@
<?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 Version20250609160739 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add embeddings to car revisions';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE car_revisions ADD COLUMN embeddings JSONB');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
}
}

View File

@ -3,6 +3,7 @@
namespace App\Application\Commands;
use App\Domain\ContentManagement\CarPropertyEmbedder;
use App\Domain\ContentManagement\CarRevisionEmbedder;
use App\Domain\Model\Cars\Brand;
use App\Domain\Model\Cars\CarModel;
use App\Domain\Model\Cars\CarRevision;
@ -62,6 +63,7 @@ class LoadFixtures extends Command
private readonly CarRevisionRepository $carRevisionRepository,
private readonly CarPropertyRepository $carPropertyRepository,
private readonly CarPropertyEmbedder $carPropertyEmbedder,
private readonly CarRevisionEmbedder $carRevisionEmbedder,
) {
parent::__construct();
}
@ -154,6 +156,7 @@ class LoadFixtures extends Command
$image
);
$this->carRevisionEmbedder->createPersistedEmbedding($carRevision, $carModel, $brand);
$this->carRevisionRepository->save($carRevision);
foreach ($revisionFixture['properties'] as $propertyValue) {
@ -399,6 +402,22 @@ class LoadFixtures extends Command
new RangeSpecification(new NefzRange(new Range(614)), new WltpRange(new Range(602))),
new CatalogPrice(new Price(129990, Currency::euro())),
]
],
[
'revision' => 'Standard Range',
'image' => new Image(externalPublicUrl: 'https://digitalassets.tesla.com/tesla-contents/image/upload/f_auto,q_auto/Model-3-Main-Hero-Desktop-LHD.jpg'),
'properties' => [
new Production(productionBegin: new Date(1, 1, 2020)),
new MotorPower(new Power(220)),
new Acceleration(6.7),
new TopSpeed(new Speed(150)),
new AverageConsumption(new Consumption(new Energy(15.5))),
new BatteryCapacity(new Energy(60.0), new Energy(66.0)),
new BatteryType(CellChemistry::LithiumNickelManganeseOxide, '2170', 'Tesla'),
new ChargingSpeed(new Power(120), new Power(120)),
new RangeSpecification(new NefzRange(new Range(450)), new WltpRange(new Range(450))),
new CatalogPrice(new Price(49990, Currency::euro())),
]
]
]
]

View File

@ -17,11 +17,21 @@ class SearchController extends AbstractController
#[Route('/s/{query}', name: 'search')]
public function index(string $query): Response
{
$decodedQuery = urldecode(str_replace('+', ' ', $query));
$decodedQuery = urldecode($query);
return $this->render('result/index.html.twig', [
'tiles' => $this->engine->search($decodedQuery)->array(),
'query' => $decodedQuery,
]);
}
#[Route('/result/{query}', name: 'result')]
public function result(string $query): Response
{
$decodedQuery = urldecode($query);
return $this->render('_components/result.html.twig', [
'tiles' => $this->engine->search($decodedQuery)->array(),
]);
}
}

View File

@ -45,7 +45,7 @@ class AIClient
public function generateJson(string $prompt): array
{
$response = $this->client->chat()->create([
'model' => 'gpt-4.1-nano',
'model' => 'gpt-4.1-mini',
'messages' => [
['role' => 'user', 'content' => $prompt],
],

View File

@ -12,6 +12,7 @@ use App\Domain\Model\Cars\CarRevision;
use App\Domain\Model\Id\EmbeddingId;
use App\Domain\Repository\EmbeddingRepository;
class CarPropertyEmbedder
{
public function __construct(
@ -20,6 +21,13 @@ class CarPropertyEmbedder
) {
}
/**
* @param CarProperty<CarPropertyValue> $carProperty
* @param CarRevision|null $carRevision
* @param CarModel|null $carModel
* @param Brand|null $brand
* @return Embedding|null
*/
public function createPersistedEmbedding(CarProperty $carProperty, ?CarRevision $carRevision, ?CarModel $carModel, ?Brand $brand): ?Embedding
{
$text = $carProperty->value->humanReadable();

View File

@ -0,0 +1,55 @@
<?php
namespace App\Domain\ContentManagement;
use App\Domain\AI\AIClient;
use App\Domain\Model\Embedding\Embedding;
use App\Domain\Model\Cars\Brand;
use App\Domain\Model\Cars\CarModel;
use App\Domain\Model\Cars\CarRevision;
use App\Domain\Model\Id\EmbeddingId;
use App\Domain\Repository\EmbeddingRepository;
class CarRevisionEmbedder
{
public function __construct(
private readonly AIClient $aiClient,
private readonly EmbeddingRepository $embeddingRepository,
) {
}
/**
* @param CarRevision $carRevision
* @param CarModel|null $carModel
* @param Brand|null $brand
* @return Embedding|null
*/
public function createPersistedEmbedding(CarRevision $carRevision, ?CarModel $carModel, ?Brand $brand): ?Embedding
{
$text = $carRevision->name;
if ($carModel !== null) {
$text .= ', ' . $carModel->name;
}
if ($brand !== null) {
$text .= ', ' . $brand->name;
}
$embedding = $this->embeddingRepository->findByPhrase($text);
if ($embedding) {
$carRevision->embeddings->add($embedding->embeddingId);
return $embedding;
}
$largeEmbedding = $this->aiClient->embedTextLarge($text);
$smallEmbedding = $this->aiClient->embedTextSmall($text);
$embedding = new Embedding(EmbeddingId::generate(), $text, $largeEmbedding, $smallEmbedding);
$this->embeddingRepository->save($embedding);
$carRevision->embeddings->add($embedding->embeddingId);
return $embedding;
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Domain\Logging;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Service\Attribute\Required;
trait LoggerTrait
{
private ?LoggerInterface $logger = null;
#[Required]
public function setLogger(LoggerInterface $logger): void
{
$this->logger = $logger;
}
}

View File

@ -3,7 +3,7 @@
namespace App\Domain\Model\Cars;
use App\Domain\Model\Id\BrandId;
use App\Domain\Model\EmbeddingCollection;
use App\Domain\Model\Embedding\EmbeddingCollection;
final readonly class Brand
{

View File

@ -4,6 +4,7 @@ namespace App\Domain\Model\Cars;
use App\Domain\Model\Id\CarModelId;
use App\Domain\Model\Id\CarRevisionId;
use App\Domain\Model\Id\EmbeddingIdCollection;
use App\Domain\Model\Image;
final readonly class CarRevision
@ -13,5 +14,6 @@ final readonly class CarRevision
public readonly CarModelId $carModelId,
public readonly string $name,
public readonly ?Image $image = null,
public readonly EmbeddingIdCollection $embeddings = new EmbeddingIdCollection([]),
) {}
}

View File

@ -1,8 +1,7 @@
<?php
namespace App\Domain\Model;
namespace App\Domain\Model\Embedding;
use App\Domain\Model\Embedding\Embedding;
class EmbeddingCollection
{

View File

@ -5,7 +5,7 @@ namespace App\Domain\Repository;
use App\Domain\Model\Embedding\Embedding;
use App\Domain\Model\Embedding\LargeEmbeddingVector;
use App\Domain\Model\Embedding\SmallEmbeddingVector;
use App\Domain\Model\EmbeddingCollection;
use App\Domain\Model\Embedding\EmbeddingCollection;
use App\Domain\Model\Id\EmbeddingIdCollection;
interface EmbeddingRepository

View File

@ -3,23 +3,26 @@
namespace App\Domain\Search;
use App\Domain\AI\AIClient;
use App\Domain\Logging\LoggerTrait;
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\Tiles\Acceleration\AccelerationTileBuilder;
use App\Domain\Search\Tiles\TileBuilder;
use App\Domain\Search\Tiles\BatteryTileBuilder;
use App\Domain\Search\TileBuilder\BrandTileBuilder;
use App\Domain\Search\TileBuilder\PriceTileBuilder;
use App\Domain\Search\Tiles\PriceTileBuilder;
use App\Domain\Search\TileBuilder\SubSectionTileBuilder;
use App\Domain\Search\TileBuilder\SectionTileBuilder;
use App\Domain\Search\View\ViewProvider;
use App\Domain\Search\View\AiViewBuilderProvider;
final class AiTileEngine implements Engine
{
use LoggerTrait;
public function __construct(
private readonly EmbeddingRepository $embeddingRepository,
private readonly CarPropertyRepository $carPropertyRepository,
@ -27,7 +30,7 @@ final class AiTileEngine implements Engine
private readonly CarRevisionRepository $carRevisionRepository,
private readonly BrandRepository $brandRepository,
private readonly AIClient $aiClient,
private readonly ViewProvider $viewProvider,
private readonly AiViewBuilderProvider $viewProvider,
) {
}
@ -116,6 +119,8 @@ final class AiTileEngine implements Engine
throw new \Exception('Invalid JSON response from AI');
}
return $view->build($data);
$this->logger?->debug('Build view {view} with data {data}', ['view' => $view, 'data' => $data]);
return $view->buildView($data);
}
}

View File

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

@ -1,6 +1,6 @@
<?php
namespace App\Domain\Search\Tiles;
namespace App\Domain\Search\Tiles\Acceleration;
use App\Domain\Model\Cars\CarPropertyValues\V1\Acceleration;

View File

@ -1,6 +1,6 @@
<?php
namespace App\Domain\Search\TileBuilder;
namespace App\Domain\Search\Tiles\Acceleration;
use App\Domain\Model\Cars\Brand;
use App\Domain\Model\Cars\CarModel;
@ -8,7 +8,8 @@ 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;
use App\Domain\Search\Tiles\TileBuilder;
use App\Domain\Search\Tiles\Acceleration\AccelerationTile;
final readonly class AccelerationTileBuilder implements TileBuilder
{

View File

@ -1,6 +1,6 @@
<?php
namespace App\Domain\Search\Tiles;
namespace App\Domain\Search\Tiles\Availability;
use App\Domain\Model\Value\Date;

View File

@ -1,14 +1,12 @@
<?php
namespace App\Domain\Search\TileBuilder;
namespace App\Domain\Search\Tiles\Availability;
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;
use App\Domain\Search\Tiles\TileBuilder;
use App\Domain\Search\Tiles\Availability\AvailabilityTile;
final readonly class AvailabilityTileBuilder implements TileBuilder
{

View File

@ -1,6 +1,6 @@
<?php
namespace App\Domain\Search\Tiles;
namespace App\Domain\Search\Tiles\Battery;
use App\Domain\Model\Battery\BatteryProperties;

View File

@ -1,6 +1,6 @@
<?php
namespace App\Domain\Search\TileBuilder;
namespace App\Domain\Search\Tiles\Battery;
use App\Domain\Model\Battery\BatteryProperties;
use App\Domain\Model\Cars\Brand;
@ -9,7 +9,8 @@ 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;
use App\Domain\Search\Tiles\TileBuilder;
use App\Domain\Search\Tiles\Battery\BatteryTile;
final readonly class BatteryTileBuilder implements TileBuilder
{

View File

@ -1,6 +1,6 @@
<?php
namespace App\Domain\Search\Tiles;
namespace App\Domain\Search\Tiles\Brand;
class BrandTile
{

View File

@ -1,6 +1,6 @@
<?php
namespace App\Domain\Search\Tiles;
namespace App\Domain\Search\Tiles\Car;
use App\Domain\Model\Image;

View File

@ -1,13 +1,12 @@
<?php
namespace App\Domain\Search\TileBuilder;
namespace App\Domain\Search\Tiles;
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

View File

@ -1,6 +1,6 @@
<?php
namespace App\Domain\Search\Tiles;
namespace App\Domain\Search\Tiles\Consumption;
use App\Domain\Model\Value\Consumption;

View File

@ -1,13 +1,14 @@
<?php
namespace App\Domain\Search\TileBuilder;
namespace App\Domain\Search\Tiles\Consumption;
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;
use App\Domain\Search\Tiles\Consumption\ConsumptionTile;
use App\Domain\Search\Tiles\TileBuilder;
final readonly class ConsumptionTileBuilder implements TileBuilder
{

View File

@ -1,6 +1,6 @@
<?php
namespace App\Domain\Search\Tiles;
namespace App\Domain\Search\Tiles\Drivetrain;
use App\Domain\Model\Value\Drivetrain;

View File

@ -1,12 +1,14 @@
<?php
namespace App\Domain\Search\TileBuilder;
namespace App\Domain\Search\Tiles\Drivetrain;
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\Drivetrain\DrivetrainTile;
use App\Domain\Search\Tiles\TileBuilder;
final readonly class DrivetrainTileBuilder implements TileBuilder
{

View File

@ -1,6 +1,6 @@
<?php
namespace App\Domain\Search\Tiles;
namespace App\Domain\Search\Tiles\Power;
use App\Domain\Model\Value\Power;

View File

@ -1,12 +1,14 @@
<?php
namespace App\Domain\Search\TileBuilder;
namespace App\Domain\Search\Tiles\Power;
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\Power\PowerTile;
use App\Domain\Search\Tiles\TileBuilder;
final readonly class PowerTileBuilder implements TileBuilder
{

View File

@ -1,6 +1,6 @@
<?php
namespace App\Domain\Search\Tiles;
namespace App\Domain\Search\Tiles\Price;
use App\Domain\Model\Value\Price;

View File

@ -1,6 +1,6 @@
<?php
namespace App\Domain\Search\TileBuilder;
namespace App\Domain\Search\Tiles\Price;
use App\Domain\Model\Cars\Brand;
use App\Domain\Model\Cars\CarModel;
@ -8,7 +8,8 @@ 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;
use App\Domain\Search\Tiles\Price\PriceTile;
use App\Domain\Search\Tiles\TileBuilder;
final readonly class PriceTileBuilder implements TileBuilder
{

View File

@ -1,6 +1,6 @@
<?php
namespace App\Domain\Search\Tiles;
namespace App\Domain\Search\Tiles\ProductionPeriod;
use App\Domain\Model\Value\Date;

View File

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

View File

@ -1,6 +1,6 @@
<?php
namespace App\Domain\Search\Tiles;
namespace App\Domain\Search\Tiles\Range;
use App\Domain\Model\Value\Range;

View File

@ -1,11 +1,12 @@
<?php
namespace App\Domain\Search\TileBuilder;
namespace App\Domain\Search\Tiles\Range;
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;
use App\Domain\Search\Tiles\Range\RangeTile;
use App\Domain\Search\Tiles\TileBuilder;
final readonly class RangeTileBuilder implements TileBuilder
{

View File

@ -1,6 +1,6 @@
<?php
namespace App\Domain\Search\Tiles;
namespace App\Domain\Search\Tiles\RealRange;
final readonly class RealRangeTile
{

View File

@ -1,6 +1,6 @@
<?php
namespace App\Domain\Search\Tiles;
namespace App\Domain\Search\Tiles\Section;
class SectionTile
{

View File

@ -1,6 +1,6 @@
<?php
namespace App\Domain\Search\Tiles;
namespace App\Domain\Search\Tiles\SubSection;
class SubSectionTile
{

View File

@ -1,6 +1,6 @@
<?php
namespace App\Domain\Search\TileBuilder;
namespace App\Domain\Search\Tiles;
use App\Domain\Model\Cars\CarProperty;
use App\Domain\Model\Cars\CarPropertyValues\V1\CarPropertyValue;

View File

@ -1,13 +1,15 @@
<?php
namespace App\Domain\Search\TileBuilder;
namespace App\Domain\Search\Tiles;
use App\Domain\Logging\LoggerTrait;
use App\Domain\Model\Cars\CarProperty;
use App\Domain\Model\Cars\CarPropertyValues\V1\CarPropertyValue;
use App\Domain\Search\TileCollection;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
final readonly class TileBuilderProvider
final class TileBuilderProvider
{
/**
* @param iterable<TileBuilder> $tileBuilders
@ -15,7 +17,10 @@ final readonly class TileBuilderProvider
public function __construct(
#[AutowireIterator('app.tile_builder')]
private iterable $tileBuilders,
) {}
private readonly ?LoggerInterface $logger = null,
) {
$this->logger?->debug('TileBuilderProvider initialized: ' . implode(', ', array_map(fn(TileBuilder $tileBuilder) => get_class($tileBuilder), iterator_to_array($this->tileBuilders))));
}
/**
* @param CarProperty<CarPropertyValue> $carProperty
@ -29,6 +34,11 @@ final readonly class TileBuilderProvider
}
}
throw new \Exception(sprintf('No tile builder found for car property %s of type %s', $carProperty->carPropertyId->value, get_class($carProperty->value)));
$this->logger?->warning('No tile builder found for car property {carPropertyId} of type {carPropertyType}', [
'carPropertyId' => $carProperty->carPropertyId->value,
'carPropertyType' => get_class($carProperty->value),
]);
return new TileCollection([]);
}
}

View File

@ -1,6 +1,6 @@
<?php
namespace App\Domain\Search\Tiles;
namespace App\Domain\Search\Tiles\TopSpeed;
use App\Domain\Model\Value\Speed;

View File

@ -1,11 +1,12 @@
<?php
namespace App\Domain\Search\TileBuilder;
namespace App\Domain\Search\Tiles\TopSpeed;
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;
use App\Domain\Search\Tiles\TopSpeed\TopSpeedTile;
use App\Domain\Search\Tiles\TileBuilder;
final readonly class TopSpeedTileBuilder implements TileBuilder
{

View File

@ -5,15 +5,15 @@ namespace App\Domain\Search\View;
use App\Domain\Search\TileCollection;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
#[AutoconfigureTag('app.view')]
interface View
#[AutoconfigureTag('app.ai_view_builder')]
interface AiViewBuilder
{
/**
* @param array<mixed> $data
*
* @return TileCollection
*/
public function build(array $data): TileCollection;
public function buildView(array $data): TileCollection;
/**
* @return array<string, mixed>

View File

@ -2,24 +2,28 @@
namespace App\Domain\Search\View;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
final readonly class ViewProvider
final readonly class AiViewBuilderProvider
{
/**
* @param iterable<View> $views
* @param iterable<AiViewBuilder> $views
*/
public function __construct(
#[AutowireIterator('app.view')]
#[AutowireIterator('app.ai_view_builder')]
private iterable $views,
) {}
private readonly ?LoggerInterface $logger = null,
) {
$this->logger?->debug('AiViewBuilderProvider initialized: ' . implode(', ', array_map(fn(AiViewBuilder $view) => get_class($view), iterator_to_array($this->views))));
}
/**
* @param string $viewClass
*
* @return View
* @return AiViewBuilder
*/
public function getView(string $viewClass): View
public function getView(string $viewClass): AiViewBuilder
{
foreach ($this->views as $view) {
$reflectionClass = new \ReflectionClass($view);
@ -34,7 +38,7 @@ final readonly class ViewProvider
}
/**
* @return array<View>
* @return array<AiViewBuilder>
*/
public function getAllViews(): array
{

View File

@ -1,31 +0,0 @@
<?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,40 @@
<?php
namespace App\Domain\Search\View\CarRevisionComparison;
use App\Domain\Model\Id\CarRevisionId;
use App\Domain\Search\TileCollection;
use App\Domain\Search\View\AiViewBuilder;
final readonly class CarRevisionComparisonAiViewBuilder implements AiViewBuilder
{
public function __construct(
private readonly CarRevisionComparisonView $carRevisionComparisonView
) {}
public function buildView(array $data): TileCollection
{
if (!is_string($data['car_revision_id_1'] ?? null)) {
throw new \InvalidArgumentException('Car revision ID 1 is required');
}
if (!is_string($data['car_revision_id_2'] ?? null)) {
throw new \InvalidArgumentException('Car revision ID 2 is required');
}
return $this->carRevisionComparisonView->buildView(new CarRevisionId($data['car_revision_id_1']), new CarRevisionId($data['car_revision_id_2']));
}
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,14 @@
<?php
namespace App\Domain\Search\View\CarRevisionComparison;
use App\Domain\Model\Id\CarRevisionId;
use App\Domain\Search\TileCollection;
final readonly class CarRevisionComparisonView
{
public function buildView(CarRevisionId $carRevisionId1, CarRevisionId $carRevisionId2): TileCollection
{
return new TileCollection([]);
}
}

View File

@ -1,19 +1,24 @@
<?php
namespace App\Domain\Search\View;
namespace App\Domain\Search\View\FullBrand;
use App\Domain\Model\Id\BrandId;
use App\Domain\Search\TileCollection;
use App\Domain\Search\View\AiViewBuilder;
final readonly class FullBrandView implements View
final readonly class FullBrandAiViewBuilder implements AiViewBuilder
{
/**
* @param array<mixed> $data
*
* @return TileCollection
*/
public function build(array $data): TileCollection
public function __construct(
private readonly FullBrandView $fullBrandView
) {}
public function buildView(array $data): TileCollection
{
return new TileCollection([]);
if (!is_string($data['brand_id'] ?? null)) {
throw new \InvalidArgumentException('Brand ID is required');
}
return $this->fullBrandView->buildView(new BrandId($data['brand_id']));
}
public function dataDescription(): array

View File

@ -0,0 +1,14 @@
<?php
namespace App\Domain\Search\View\FullBrand;
use App\Domain\Model\Id\BrandId;
use App\Domain\Search\TileCollection;
final readonly class FullBrandView
{
public function buildView(BrandId $brandId): TileCollection
{
return new TileCollection([]);
}
}

View File

@ -0,0 +1,57 @@
<?php
namespace App\Domain\Search\View\FullCarModel;
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\Tiles\TileBuilderProvider;
use App\Domain\Search\Tiles\CarTile;
use App\Domain\Search\Tiles\SectionTile;
use App\Domain\Search\Tiles\SubSectionTile;
use App\Domain\Search\View\AiViewBuilder;
final readonly class FullCarModelAiViewBuilder implements AiViewBuilder
{
public function __construct(
private readonly FullCarModelView $fullCarModelView
) {}
/**
* @param array<mixed> $data
*
* @return TileCollection
*/
public function buildView(array $data): TileCollection
{
if (!is_string($data['car_model_id'] ?? null)) {
throw new \InvalidArgumentException('Car model ID is required');
}
return $this->fullCarModelView->buildView(new CarModelId($data['car_model_id']));
}
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

@ -1,6 +1,6 @@
<?php
namespace App\Domain\Search\View;
namespace App\Domain\Search\View\FullCarModel;
use App\Domain\Model\Cars\CarPropertyValues\V1\Acceleration;
use App\Domain\Model\Cars\CarPropertyValues\V1\Production;
@ -9,35 +9,25 @@ 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;
use App\Domain\Search\Tiles\Car\CarTile;
use App\Domain\Search\Tiles\TileBuilderProvider;
use App\Domain\Search\Tiles\Section\SectionTile;
use App\Domain\Search\Tiles\SubSection\SubSectionTile;
final readonly class FullCarModelView implements View
/** @package App\Domain\Search\View\FullCarModel */
final readonly class FullCarModelView
{
public function __construct(
private readonly FullCarLoader $fullCarLoader,
private readonly TileBuilderProvider $tileBuilderProvider,
) {}
/**
* @param array<mixed> $data
*
* @return TileCollection
*/
public function build(array $data): TileCollection
public function buildView(CarModelId $carModelId): TileCollection
{
if (!is_string($data['car_model_id'] ?? null)) {
throw new \InvalidArgumentException('Car model ID is required');
}
$fullCarModel = $this->fullCarLoader->loadModel($carModelId);
$fullCarModel = $this->fullCarLoader->loadModel(new CarModelId($data['car_model_id']));
$carModel = $fullCarModel->getCarModel();
$brand = $fullCarModel->getBrand();
$carRevisions = $fullCarModel->getCarRevisions();
@ -75,19 +65,4 @@ final readonly class FullCarModelView implements View
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,56 @@
<?php
namespace App\Domain\Search\View\FullCarRevision;
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\Cars\CarPropertyValues\V1\AverageConsumption;
use App\Domain\Model\Cars\CarPropertyValues\V1\Battery\BatteryCapacity;
use App\Domain\Model\Cars\CarPropertyValues\V1\MotorPower;
use App\Domain\Model\Id\CarRevisionId;
use App\Domain\Repository\Loader\FullCarLoader;
use App\Domain\Search\Tiles\TileBuilderProvider;
use App\Domain\Search\TileCollection;
use App\Domain\Search\Tiles\CarTile;
use App\Domain\Search\Tiles\SectionTile;
use App\Domain\Search\View\AiViewBuilder;
final readonly class FullCarRevisionAiBuilder implements AiViewBuilder
{
public function __construct(
private readonly FullCarRevisionView $fullCarRevisionView
) {}
/**
* @param array<mixed> $data
*
* @return TileCollection
*/
public function buildView(array $data): TileCollection
{
if (!is_string($data['car_revision_id'] ?? null)) {
throw new \InvalidArgumentException('Car revision ID is required');
}
return $this->fullCarRevisionView->buildView(new CarRevisionId($data['car_revision_id']));
}
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

@ -1,6 +1,6 @@
<?php
namespace App\Domain\Search\View;
namespace App\Domain\Search\View\FullCarRevision;
use App\Domain\Model\Cars\CarPropertyValues\V1\Acceleration;
use App\Domain\Model\Cars\CarPropertyValues\V1\Production;
@ -8,33 +8,26 @@ 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\Cars\CarPropertyValues\V1\AverageConsumption;
use App\Domain\Model\Cars\CarPropertyValues\V1\Battery\BatteryCapacity;
use App\Domain\Model\Cars\CarPropertyValues\V1\MotorPower;
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\Tiles\TileBuilderProvider;
use App\Domain\Search\TileCollection;
use App\Domain\Search\Tiles\CarTile;
use App\Domain\Search\Tiles\SectionTile;
use App\Domain\Search\Tiles\Car\CarTile;
use App\Domain\Search\Tiles\Section\SectionTile;
final readonly class FullCarRevisionView implements View
final readonly class FullCarRevisionView
{
public function __construct(
private readonly FullCarLoader $fullCarLoader,
private readonly TileBuilderProvider $tileBuilderProvider
) {}
/**
* @param array<mixed> $data
*
* @return TileCollection
*/
public function build(array $data): TileCollection
public function buildView(CarRevisionId $carRevisionId): 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']));
$fullCar = $this->fullCarLoader->loadRevision($carRevisionId);
$carRevision = $fullCar->getCarRevision();
$carModel = $fullCar->getCarModel();
@ -47,6 +40,9 @@ final readonly class FullCarRevisionView implements View
$carProperties->getOne(TopSpeed::class),
$carProperties->getOne(Acceleration::class),
$carProperties->getOne(RangeSpecification::class),
$carProperties->getOne(MotorPower::class),
$carProperties->getOne(AverageConsumption::class),
$carProperties->getOne(BatteryCapacity::class),
], static fn($value) => $value !== null);
$tiles = new TileCollection([]);
@ -63,19 +59,4 @@ final readonly class FullCarRevisionView implements View
),
]);
}
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,47 @@
<?php
namespace App\Domain\Search\View\SpecificCarProperty;
use App\Domain\Model\Id\CarPropertyId;
use App\Domain\Search\TileCollection;
use App\Domain\Search\View\AiViewBuilder;
final readonly class SpecificCarPropertyAiViewBuilder implements AiViewBuilder
{
public function __construct(
private readonly SpecificCarPropertyView $specificCarPropertyView
) {}
/**
* @param array<mixed> $data
*
* @return TileCollection
*/
public function buildView(array $data): TileCollection
{
if (!is_array($data['properties'] ?? null)) {
throw new \InvalidArgumentException('Properties must be an array');
}
/** @var array<CarPropertyId|null> $propertyIds */
$propertyIds = array_map(fn($propertyId) => is_string($propertyId) ? new CarPropertyId($propertyId) : null, $data['properties']);
$propertyIds = array_filter($propertyIds, static fn($propertyId) => $propertyId !== null);
return $this->specificCarPropertyView->buildView($propertyIds);
}
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,44 @@
<?php
namespace App\Domain\Search\View\SpecificCarProperty;
use App\Domain\Model\Id\CarPropertyId;
use App\Domain\Repository\CarPropertyRepository;
use App\Domain\Repository\Loader\FullCarLoader;
use App\Domain\Search\Tiles\TileBuilderProvider;
use App\Domain\Search\TileCollection;
use App\Domain\Search\Tiles\Section\SectionTile;
final readonly class SpecificCarPropertyView
{
public function __construct(
private readonly TileBuilderProvider $tileBuilderProvider,
private readonly FullCarLoader $fullCarLoader,
private readonly CarPropertyRepository $carPropertyRepository,
) {}
/**
* @param array<CarPropertyId> $carPropertyIds
*
* @return TileCollection
*/
public function buildView(array $carPropertyIds): TileCollection
{
$tiles = [];
foreach ($carPropertyIds as $carPropertyId) {
$carProperty = $this->carPropertyRepository->findById($carPropertyId);
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);
}
}

View File

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

@ -2,18 +2,21 @@
namespace App\Infrastructure\PostgreSQL\Repository\EmbeddingRepository;
use App\Domain\Logging\LoggerTrait;
use Doctrine\DBAL\Connection;
use App\Domain\Repository\EmbeddingRepository;
use App\Domain\Model\Embedding\Embedding;
use App\Domain\Model\Embedding\LargeEmbeddingVector;
use App\Domain\Model\Embedding\SmallEmbeddingVector;
use App\Domain\Model\EmbeddingCollection;
use App\Domain\Model\Embedding\EmbeddingCollection;
use App\Domain\Model\Value\Vector;
use App\Domain\Model\Id\EmbeddingId;
use App\Domain\Model\Id\EmbeddingIdCollection;
final class SqlEmbeddingRepository implements EmbeddingRepository
{
use LoggerTrait;
public function __construct(
private readonly Connection $connection,
) {}
@ -66,29 +69,33 @@ final class SqlEmbeddingRepository implements EmbeddingRepository
return $this->mapRowToEmbedding($row);
}
public function searchByLargeEmbeddingVector(LargeEmbeddingVector $embeddingVector, int $limit = 20): EmbeddingCollection
public function searchByLargeEmbeddingVector(LargeEmbeddingVector $embeddingVector, int $limit = 99): EmbeddingCollection
{
$result = $this->connection->executeQuery(
'SELECT *, large_embedding_vector <=> :embeddingVector AS distance
FROM embeddings
WHERE large_embedding_vector IS NOT NULL
ORDER BY large_embedding_vector <=> :embeddingVector
LIMIT :limit',
ORDER BY large_embedding_vector <=> :embeddingVector',
[
'embeddingVector' => '[' . implode(',', $embeddingVector->vector->values) . ']',
'limit' => $limit,
]
);
$embeddings = [];
foreach ($result->fetchAllAssociative() as $row) {
if ($row['distance'] > 0.5) {
continue;
}
$this->logger?->debug('Found embedding {embedding} with distance {distance}', ['embedding' => $row['phrase'], 'distance' => $row['distance']]);
$embeddings[] = $this->mapRowToEmbedding($row);
}
return new EmbeddingCollection($embeddings);
}
public function searchBySmallEmbeddingVector(SmallEmbeddingVector $smallEmbeddingVector, int $limit = 20): EmbeddingCollection
public function searchBySmallEmbeddingVector(SmallEmbeddingVector $smallEmbeddingVector, int $limit = 99): EmbeddingCollection
{
$result = $this->connection->executeQuery(
'SELECT *

View File

@ -136,6 +136,18 @@
"ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
}
},
"symfony/monolog-bundle": {
"version": "3.10",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.7",
"ref": "aff23899c4440dd995907613c1dd709b6f59503f"
},
"files": [
"config/packages/monolog.yaml"
]
},
"symfony/routing": {
"version": "7.2",
"recipe": {

View File

@ -0,0 +1,3 @@
<div class="tiles-grid">
{% include 'result/tiles/collection.html.twig' with { tiles: tiles } %}
</div>

View File

@ -1,7 +1,8 @@
<div class="search-container" id="searchForm">
<input type="text" id="searchInput" class="search-input" placeholder="🔍 Search for electric vehicles, brands, or models..." value="{{ query|default('') }}">
<button type="button" id="searchButton" class="search-button">
<i class="fas fa-search"></i> Search
<span class="search-btn-content"><i class="fas fa-search"></i> Search</span>
<span class="search-btn-spinner" style="display:none;"></span>
</button>
</div>
@ -9,39 +10,380 @@
document.addEventListener('DOMContentLoaded', function() {
const searchForm = document.getElementById('searchForm');
const searchInput = document.getElementById('searchInput');
const searchButton = document.getElementById('searchButton');
const resultsContainer = document.getElementById('resultsContainer');
function encodeSearchQuery(query) {
query = query.replace(/[^a-zA-Z0-9+\-\s]/g, '');
return encodeURIComponent(query);
}
function setLoading(isLoading) {
if (isLoading) {
searchForm.classList.add('loading');
searchButton.querySelector('.search-btn-content').style.display = 'none';
searchButton.querySelector('.search-btn-spinner').style.display = 'inline-block';
if (resultsContainer) {
if (resultsContainer.innerHTML.trim()) {
// If there is already content, just fade it
resultsContainer.classList.add('loading-fade');
} else {
// If no content, show placeholders
fadeOutIn(resultsContainer, `
<div class="placeholder-glow placeholder-detail-layout">
<div class="placeholder-header">
<div class="placeholder placeholder-title-main"></div>
<div class="placeholder placeholder-title-sub"></div>
</div>
<div class="placeholder-content-row">
<div class="placeholder-image-col">
<div class="placeholder placeholder-large-img"></div>
</div>
<div class="placeholder-info-col">
<div class="placeholder-info-grid">
<div class="placeholder-info-block">
<div class="placeholder placeholder-icon"></div>
<div class="placeholder placeholder-value"></div>
<div class="placeholder placeholder-label"></div>
</div>
<div class="placeholder-info-block">
<div class="placeholder placeholder-icon"></div>
<div class="placeholder placeholder-value"></div>
<div class="placeholder placeholder-label"></div>
</div>
<div class="placeholder-info-block">
<div class="placeholder placeholder-icon"></div>
<div class="placeholder placeholder-value"></div>
<div class="placeholder placeholder-label"></div>
</div>
<div class="placeholder-info-block">
<div class="placeholder placeholder-icon"></div>
<div class="placeholder placeholder-value"></div>
<div class="placeholder placeholder-label"></div>
</div>
</div>
</div>
</div>
</div>
`, () => {
resultsContainer.style.display = 'block';
});
}
}
} else {
searchForm.classList.remove('loading');
// Hide spinner, show button content
searchButton.querySelector('.search-btn-content').style.display = 'inline-block';
searchButton.querySelector('.search-btn-spinner').style.display = 'none';
if (resultsContainer) {
resultsContainer.classList.remove('loading-fade');
}
}
}
function fadeOutIn(element, newContent, callback) {
element.classList.add('fade-out');
setTimeout(() => {
element.innerHTML = newContent;
element.classList.remove('fade-out');
element.classList.add('fade-in');
setTimeout(() => {
element.classList.remove('fade-in');
if (callback) callback();
}, 150); // match the CSS transition duration
}, 150); // match the CSS transition duration
}
function performSearch(query) {
if (!query.trim()) {
return;
}
const encodedQuery = encodeSearchQuery(query);
// Show loading animation on search bar
setLoading(true);
// Update URL without page reload
const newUrl = `/s/${encodedQuery}`;
window.history.pushState({ query: query }, '', newUrl);
// Make AJAX request to get results
fetch(`/result/${encodedQuery}`)
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.text();
})
.then(html => {
// Hide loading animation
setLoading(false);
fadeOutIn(resultsContainer, html, () => {
resultsContainer.style.display = 'block';
});
})
.catch(error => {
console.error('Search error:', error);
// Hide loading animation
setLoading(false);
// Show error message
fadeOutIn(resultsContainer, '<div class="no-results">Sorry, there was an error performing your search. Please try again.</div>', () => {
resultsContainer.style.display = 'block';
});
});
}
// Handle form submission
searchForm.addEventListener('submit', function(e) {
e.preventDefault();
const query = searchInput.value.trim();
if (query) {
const encodedQuery = encodeSearchQuery(query);
window.location.href = `/s/${encodedQuery}`;
}
performSearch(query);
});
// Handle search button click
searchButton.addEventListener('click', function(e) {
e.preventDefault();
const query = searchInput.value.trim();
if (query) {
const encodedQuery = encodeSearchQuery(query);
window.location.href = `/s/${encodedQuery}`;
}
performSearch(query);
});
// Handle Enter key press
searchInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
const query = searchInput.value.trim();
if (query) {
const encodedQuery = encodeSearchQuery(query);
window.location.href = `/s/${encodedQuery}`;
}
performSearch(query);
}
});
// Handle browser back/forward buttons
window.addEventListener('popstate', function(event) {
if (event.state && event.state.query) {
searchInput.value = event.state.query;
performSearch(event.state.query);
}
});
// Check for initial results on the page
const initialResults = document.getElementById('initialResults');
const initialQuery = searchInput.value.trim();
if (initialResults && initialResults.innerHTML.trim()) {
// Show initial results if they exist
resultsContainer.innerHTML = initialResults.innerHTML;
resultsContainer.style.display = 'block';
} else if (initialQuery) {
// If there's a query but no initial results, perform search
performSearch(initialQuery);
}
});
</script>
</script>
<style>
.search-container.loading {
position: relative;
}
.no-results {
text-align: center;
padding: 2rem;
color: #999;
font-style: italic;
}
.search-btn-spinner {
width: 1.2em;
height: 1.2em;
border: 2.5px solid #e0e0e0;
border-top: 2.5px solid #083d77;
border-radius: 50%;
animation: search-btn-spin 0.8s linear infinite;
vertical-align: middle;
margin-left: 0.2em;
display: none;
}
@keyframes search-btn-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.placeholder-glow {
display: block;
animation: placeholder-glow 2s ease-in-out infinite;
}
@keyframes placeholder-glow {
0% { opacity: 0.5; }
50% { opacity: 1; }
100% { opacity: 0.5; }
}
.placeholder {
display: block;
background-color:rgb(198, 198, 198);
height: 1.2rem;
width: 100%;
vertical-align: middle;
margin-left: 0.2em;
margin-bottom: 0.5rem;
}
.placeholder.col-6 {
width: 60%;
}
.placeholder.col-7 {
width: 70%;
}
.placeholder.col-4 {
width: 40%;
}
.placeholder.col-8 {
width: 80%;
}
.placeholder-tiles {
display: flex;
gap: 2rem;
justify-content: stretch;
margin-top: 2rem;
}
.placeholder-tile {
background: #f6f6f6;
padding: 1.5rem 1.2rem 1.2rem 1.2rem;
width: 260px;
display: flex;
flex-direction: column;
align-items: stretch;
min-height: 320px;
}
.placeholder-img {
width: 90px;
height: 90px;
background-color: #d2d2d2;
margin-bottom: 1.2rem;
}
.placeholder-title {
width: 70%;
height: 1.3rem;
margin-bottom: 0.7rem;
}
.placeholder-subtitle {
width: 50%;
height: 1rem;
margin-bottom: 1.1rem;
}
.placeholder-spec {
width: 80%;
height: 0.9rem;
margin-bottom: 0.5rem;
}
.placeholder-spec.short {
width: 40%;
}
.placeholder-btn {
width: 60%;
height: 1.2rem;
border-radius: 0.6rem;
margin-top: 1.2rem;
}
.placeholder-detail-layout {
display: flex;
flex-direction: column;
gap: 2.5rem;
margin-top: 2rem;
width: 100%;
}
.placeholder-header {
margin-bottom: 0.5rem;
}
.placeholder-title-main {
width: 320px;
height: 2.2rem;
margin-bottom: 0.7rem;
}
.placeholder-title-sub {
width: 220px;
height: 1.2rem;
margin-bottom: 0.7rem;
}
.placeholder-content-row {
display: flex;
flex-direction: row;
gap: 2.5rem;
width: 100%;
}
.placeholder-image-col {
flex: 1.2;
display: flex;
align-items: flex-start;
justify-content: flex-start;
}
.placeholder-large-img {
width: 100%;
max-width: 520px;
height: 260px;
border-radius: 0.7rem;
background-color: #d2d2d2;
}
.placeholder-info-col {
flex: 1.5;
display: flex;
align-items: flex-start;
justify-content: flex-start;
}
.placeholder-info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 2.2rem 2.5rem;
width: 100%;
}
.placeholder-info-block {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.placeholder-icon {
width: 2.2rem;
height: 2.2rem;
border-radius: 50%;
background-color: #e0e0e0;
margin-bottom: 0.3rem;
}
.placeholder-value {
width: 90px;
height: 1.3rem;
margin-bottom: 0.2rem;
}
.placeholder-label {
width: 120px;
height: 0.9rem;
background-color: #eaeaea;
}
#resultsContainer {
opacity: 1;
transition: opacity 0.15s ease;
}
#resultsContainer.fade-out {
opacity: 0;
pointer-events: none;
}
#resultsContainer.fade-in {
opacity: 1;
}
/* Add fade for loading existing content */
#resultsContainer.loading-fade {
opacity: 0.3;
transition: opacity 0.3s ease;
}
</style>

View File

@ -263,7 +263,6 @@
.loading {
text-align: center;
padding: 2rem;
color: #666;
}

View File

@ -8,4 +8,5 @@
{% include '_components/search.html.twig' %}
</div>
<div id="resultsContainer" class="results-container"></div>
{% endblock %}

View File

@ -6,8 +6,10 @@
{% include '_components/search.html.twig' with { query: query } %}
</div>
<div class="tiles-grid">
{% include 'result/tiles/collection.html.twig' with { tiles: tiles } %}
</div>
{% if tiles is defined and tiles|length > 0 %}
<div id="resultsContainer" class="results-container">
{% include '_components/result.html.twig' with { tiles: tiles } %}
</div>
{% endif %}
{% endblock %}